Compare commits
19 commits
8cdb51b4b4
...
a3f8b49549
Author | SHA1 | Date | |
---|---|---|---|
|
a3f8b49549 | ||
|
1b3d6b2b90 | ||
|
98262fc4ee | ||
|
63ff6cc867 | ||
|
66a38f2969 | ||
|
9100ae3066 | ||
|
006158c731 | ||
|
9d5860a53f | ||
|
e53eca1831 | ||
|
0e09c28150 | ||
|
a0f55c4a95 | ||
|
f0d707b071 | ||
|
1e2cd8b5d2 | ||
|
90bfe7c7b8 | ||
|
7f8ea54c6e | ||
|
36d7e081f1 | ||
|
d4deae57ef | ||
|
881778b313 | ||
|
f62f658be8 |
2
.github/workflows/build_apk.yml
vendored
|
@ -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
|
||||
|
|
35
.github/workflows/create_release.yml
vendored
|
@ -28,6 +28,9 @@ on:
|
|||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
RELEASE_NOTES_FILE: release_notes_en_${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
run-integration-tests:
|
||||
name: Run integration tests
|
||||
|
@ -67,14 +70,13 @@ jobs:
|
|||
steps:
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
echo ${{ inputs.release-notes }} > whatsnew-en-US.md
|
||||
perl -i -pe 's/\s{1}(-{1})/\n$1/g' whatsnew-en-US.md
|
||||
|
||||
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: whatsnew-en-US
|
||||
path: whatsnew-en-US.md
|
||||
name: ${{ env.RELEASE_NOTES_FILE }}
|
||||
path: ${{ env.RELEASE_NOTES_FILE }}.md
|
||||
|
||||
create-github-release:
|
||||
name: Create Github release
|
||||
|
@ -90,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"
|
||||
|
@ -112,17 +122,12 @@ jobs:
|
|||
- name: Rename apk
|
||||
run: mv app-prod-release.apk m3_lightmeter.apk
|
||||
|
||||
- name: Download release notes
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: whatsnew-en-US
|
||||
|
||||
- uses: ncipollo/release-action@v1.12.0
|
||||
with:
|
||||
artifacts: "m3_lightmeter.apk"
|
||||
skipIfReleaseExists: true
|
||||
tag: "v${{ github.event.inputs.version }}"
|
||||
bodyFile: "whatsnew-en-US.md"
|
||||
bodyFile: "${{ env.RELEASE_NOTES_FILE }}.md"
|
||||
|
||||
create-google-play-release:
|
||||
name: Create Google Play release
|
||||
|
@ -146,13 +151,13 @@ jobs:
|
|||
- name: Download release notes
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: whatsnew-en-US
|
||||
name: ${{ env.RELEASE_NOTES_FILE }}
|
||||
|
||||
- name: Move release notes to a folder
|
||||
run: |
|
||||
mv whatsnew-en-US.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
|
||||
|
@ -207,4 +212,4 @@ jobs:
|
|||
m3_lightmeter_apk
|
||||
m3_lightmeter_appbundle
|
||||
m3_lightmeter_ipa
|
||||
whatsnew-en-US
|
||||
${{ env.RELEASE_NOTES_FILE }}
|
||||
|
|
2
.gitignore
vendored
|
@ -63,4 +63,4 @@ ios/Runner/GoogleService-Info.plist
|
|||
coverage/
|
||||
test/coverage_helper_test.dart
|
||||
**/failures/*.png
|
||||
screenshots/generated/
|
||||
screenshots/generated/raw/
|
10
README.md
|
@ -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
|
||||
|
|
|
@ -52,7 +52,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 34
|
||||
ndk {
|
||||
debugSymbolLevel 'FULL'
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a'
|
||||
|
|
4
assets/release_notes/release_notes_en_0.19.0.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
- [Pro] Added a timer for shooting long exposures without leaving the app, just tap the necessary shutter speed in the list.
|
||||
- Long exposure values are no longer limited by 1" and are generated to match the list of aperture values.
|
||||
- Refined the app's color palette & unified icons across the app.
|
||||
- Added release notes dialog.
|
3
assets/release_notes/release_notes_en_0.20.0.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
- Added ISO values up to 25600.
|
||||
- Improved Pro features description.
|
||||
- Made "Restore purchases" option more accessible.
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ class IAPProductsProviderState extends State<IAPProductsProvider> {
|
|||
IAPProduct(
|
||||
storeId: IAPProductType.paidFeatures.storeId,
|
||||
status: IAPProductStatus.purchased,
|
||||
price: '0.0\$',
|
||||
)
|
||||
],
|
||||
child: widget.child,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
),
|
||||
|
|
56
lib/data/models/app_feature.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -32,6 +32,8 @@ class UserPreferencesService {
|
|||
static const primaryColorKey = "primaryColor";
|
||||
static const dynamicColorKey = "dynamicColor";
|
||||
|
||||
static const seenChangelogVersionKey = "seenChangelogVersion";
|
||||
|
||||
final SharedPreferences _sharedPreferences;
|
||||
|
||||
UserPreferencesService(this._sharedPreferences) {
|
||||
|
@ -157,4 +159,7 @@ class UserPreferencesService {
|
|||
|
||||
bool get dynamicColor => _sharedPreferences.getBool(dynamicColorKey) ?? false;
|
||||
set dynamicColor(bool value) => _sharedPreferences.setBool(dynamicColorKey, value);
|
||||
|
||||
String get seenChangelogVersion => _sharedPreferences.getString(seenChangelogVersionKey) ?? '';
|
||||
set seenChangelogVersion(String value) => _sharedPreferences.setString(seenChangelogVersionKey, value);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
@ -119,5 +140,15 @@
|
|||
"tooltipUseLightSensor": "Use lightsensor",
|
||||
"tooltipUseCamera": "Use camera",
|
||||
"tooltipOpenSettings": "Open settings",
|
||||
"exposurePair": "Exposure pair"
|
||||
"exposurePair": "Exposure pair",
|
||||
"whatsnew": "What's new?",
|
||||
"changesInVersion": "Changes in version {version}:",
|
||||
"@changesInVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"close": "Close"
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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": "Развернуть",
|
||||
|
|
|
@ -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": "展开",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
229
lib/screens/lightmeter_pro/screen_lightmeter_pro.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:lightmeter/generated/l10n.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:lightmeter/screens/shared/release_notes_dialog/widget_dialog_release_notes.dart';
|
||||
import 'package:lightmeter/utils/platform_utils.dart';
|
||||
|
||||
class VersionListTile extends StatelessWidget {
|
||||
const VersionListTile({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(S.of(context).version),
|
||||
trailing: FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (context, snapshot) => snapshot.data != null
|
||||
? Text(S.of(context).versionNumber(snapshot.data!.version, snapshot.data!.buildNumber))
|
||||
: const SizedBox.shrink(),
|
||||
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),
|
||||
onTap: snapshot.data != null
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => ReleaseNotesDialog(version: snapshot.data!.version),
|
||||
)
|
||||
: null,
|
||||
trailing: Text(S.of(context).versionNumber(snapshot.data?.version ?? '', snapshot.data?.buildNumber ?? '')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
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:lightmeter/utils/platform_utils.dart';
|
||||
|
||||
class ReleaseNotesBloc extends Cubit<ReleaseNotesState> {
|
||||
final UserPreferencesService _userPreferencesService;
|
||||
final PlatformUtils _platformUtils;
|
||||
|
||||
ReleaseNotesBloc(
|
||||
this._userPreferencesService,
|
||||
this._platformUtils,
|
||||
) : super(const HiddenReleaseNotesDialogState()) {
|
||||
_showDialogIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> _showDialogIfNeeded() async {
|
||||
_platformUtils.version.then((version) {
|
||||
if (version != _userPreferencesService.seenChangelogVersion) {
|
||||
emit(ShowReleaseNotesDialogState(version));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setChangelogVersion() {
|
||||
_userPreferencesService.seenChangelogVersion = (state as ShowReleaseNotesDialogState).version;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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;
|
||||
|
||||
const ReleaseNotesFlow({required this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ReleaseNotesBloc(
|
||||
ServicesProvider.of(context).userPreferencesService,
|
||||
const PlatformUtils(),
|
||||
),
|
||||
child: BlocListener<ReleaseNotesBloc, ReleaseNotesState>(
|
||||
listener: (context, state) {
|
||||
if (state is ShowReleaseNotesDialogState) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ReleaseNotesDialog(version: state.version),
|
||||
).then((_) => context.read<ReleaseNotesBloc>().setChangelogVersion());
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
sealed class ReleaseNotesState {
|
||||
const ReleaseNotesState();
|
||||
}
|
||||
|
||||
class HiddenReleaseNotesDialogState extends ReleaseNotesState {
|
||||
const HiddenReleaseNotesDialogState();
|
||||
}
|
||||
|
||||
class ShowReleaseNotesDialogState extends ReleaseNotesState {
|
||||
final String version;
|
||||
|
||||
const ShowReleaseNotesDialogState(this.version);
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:lightmeter/data/models/supported_locale.dart';
|
||||
import 'package:lightmeter/generated/l10n.dart';
|
||||
import 'package:lightmeter/providers/user_preferences_provider.dart';
|
||||
import 'package:lightmeter/res/dimens.dart';
|
||||
|
||||
class ReleaseNotesDialog extends StatelessWidget {
|
||||
final String version;
|
||||
|
||||
const ReleaseNotesDialog({required this.version, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).whatsnew),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).changesInVersion(version),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: Dimens.grid8),
|
||||
FutureBuilder<String>(
|
||||
future: loadReleaseNotes(context),
|
||||
builder: (context, snapshot) => Text(snapshot.data ?? ''),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text(S.of(context).close),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> loadReleaseNotes(BuildContext context) async {
|
||||
late final String localeName;
|
||||
|
||||
switch (UserPreferencesProvider.localeOf(context)) {
|
||||
case SupportedLocale.ru:
|
||||
localeName = SupportedLocale.ru.name;
|
||||
default:
|
||||
localeName = SupportedLocale.en.name;
|
||||
}
|
||||
|
||||
try {
|
||||
return rootBundle.loadString('assets/release_notes/release_notes_${localeName}_$version.md');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
10
lib/utils/platform_utils.dart
Normal 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));
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
15
pubspec.yaml
|
@ -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,20 +53,27 @@ 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:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/camera_stub_image.jpg
|
||||
- assets/release_notes/
|
||||
|
||||
flutter_intl:
|
||||
enabled: true
|
||||
|
|
|
@ -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)
|
||||
|
|
44
screenshots/assets/content/screenshot_titles_en.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
BIN
screenshots/assets/fonts/SF-Pro-Display-Bold-dark.zip
Normal file
BIN
screenshots/assets/fonts/SF-Pro-Display-Bold.zip
Normal file
BIN
screenshots/assets/fonts/SF-Pro-Display-Regular-dark.zip
Normal file
BIN
screenshots/assets/fonts/SF-Pro-Display-Regular.zip
Normal file
BIN
screenshots/assets/frames/android/pixel_6_frame.png
Normal file
After Width: | Height: | Size: 198 KiB |
BIN
screenshots/assets/frames/ios/iphone_13_pro_frame.png
Normal file
After Width: | Height: | Size: 219 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 16 KiB |
189
screenshots/convert_to_store_screenshots.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 492 KiB |
After Width: | Height: | Size: 221 KiB |
After Width: | Height: | Size: 221 KiB |
After Width: | Height: | Size: 235 KiB |
After Width: | Height: | Size: 220 KiB |
After Width: | Height: | Size: 500 KiB |
BIN
screenshots/generated/android/android/light_settings.png
Normal file
After Width: | Height: | Size: 235 KiB |
BIN
screenshots/generated/android/android/light_timer.png
Normal file
After Width: | Height: | Size: 219 KiB |
After Width: | Height: | Size: 346 KiB |
After Width: | Height: | Size: 159 KiB |
After Width: | Height: | Size: 163 KiB |
After Width: | Height: | Size: 177 KiB |
After Width: | Height: | Size: 345 KiB |
BIN
screenshots/generated/ios/iphone55inch/light_settings.png
Normal file
After Width: | Height: | Size: 167 KiB |
BIN
screenshots/generated/ios/iphone55inch/light_timer.png
Normal file
After Width: | Height: | Size: 148 KiB |
After Width: | Height: | Size: 507 KiB |
After Width: | Height: | Size: 223 KiB |
After Width: | Height: | Size: 227 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 501 KiB |
BIN
screenshots/generated/ios/iphone65inch/light_settings.png
Normal file
After Width: | Height: | Size: 235 KiB |
BIN
screenshots/generated/ios/iphone65inch/light_timer.png
Normal file
After Width: | Height: | Size: 206 KiB |
60
screenshots/models/screenshot_args.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
19
screenshots/models/screenshot_config.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
49
screenshots/models/screenshot_device.dart
Normal 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),
|
||||
),
|
||||
];
|
35
screenshots/models/screenshot_layout.dart
Normal 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,
|
||||
});
|
||||
}
|
4
screenshots/scripts/convert_to_store_screenshots.sh
Normal 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
|
1
screenshots/scripts/generate_android_screenshots.sh
Normal file
|
@ -0,0 +1 @@
|
|||
sh screenshots/scripts/generate_screenshots.sh "Pixel 6"
|
|
@ -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'
|
||||
|
|
14
screenshots/utils/parse_configs.dart
Normal 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);
|
||||
}
|
|
@ -91,6 +91,7 @@ class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
|
|||
IAPProduct(
|
||||
storeId: IAPProductType.paidFeatures.storeId,
|
||||
status: widget.productStatus,
|
||||
price: '0.0\$',
|
||||
),
|
||||
],
|
||||
child: ApplicationWrapper(
|
||||
|
|
|
@ -26,6 +26,7 @@ void main() {
|
|||
IAPProduct(
|
||||
storeId: IAPProductType.paidFeatures.storeId,
|
||||
status: productStatus,
|
||||
price: '0.0\$',
|
||||
),
|
||||
],
|
||||
child: EquipmentProfileProvider(
|
||||
|
|
|
@ -26,6 +26,7 @@ void main() {
|
|||
IAPProduct(
|
||||
storeId: IAPProductType.paidFeatures.storeId,
|
||||
status: productStatus,
|
||||
price: '0.0\$',
|
||||
),
|
||||
],
|
||||
child: FilmsProvider(
|
||||
|
|
BIN
test/screens/lightmeter_pro/goldens/lightmeter_pro_screen.png
Normal file
After Width: | Height: | Size: 301 KiB |
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ void main() {
|
|||
IAPProduct(
|
||||
storeId: IAPProductType.paidFeatures.storeId,
|
||||
status: IAPProductStatus.purchased,
|
||||
price: '0.0\$',
|
||||
),
|
||||
],
|
||||
child: EquipmentProfileProvider(
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -27,6 +27,7 @@ void main() {
|
|||
IAPProduct(
|
||||
storeId: IAPProductType.paidFeatures.storeId,
|
||||
status: IAPProductStatus.purchased,
|
||||
price: '0.0\$',
|
||||
),
|
||||
],
|
||||
child: FilmsProvider(
|
||||
|
|
|
@ -79,7 +79,7 @@ extension WidgetTesterActions on WidgetTester {
|
|||
children: [
|
||||
Expanded(
|
||||
child: AnimatedDialogPicker<int>(
|
||||
icon: Icons.iso,
|
||||
icon: Icons.iso_outlined,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
selectedValue: 0,
|
||||
|
|
|
@ -51,7 +51,7 @@ extension WidgetTesterActions on WidgetTester {
|
|||
children: [
|
||||
Expanded(
|
||||
child: AnimatedDialogPicker<int>(
|
||||
icon: Icons.iso,
|
||||
icon: Icons.iso_outlined,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
selectedValue: 0,
|
||||
|
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
@ -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);
|
||||
|
|