diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3ea37a2..a2ba13d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,14 +2,9 @@ name: Android CI on: push: - branches: - - main - - develop - - issue-* + branches: ["main"] pull_request: - branches: - - main - - develop + branches: ["main"] jobs: build: @@ -23,7 +18,7 @@ jobs: # Step 2: Set up JDK 17 with Gradle Caching - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: "zulu" java-version: "17" diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml new file mode 100644 index 0000000..4ccadc0 --- /dev/null +++ b/.github/workflows/flutter.yml @@ -0,0 +1,77 @@ +name: Flutter + +on: + push: + paths: ['BalanceFlutter/**', '.github/workflows/flutter.yml', 'Balance/BalanceWatch/**', 'Balance/Balance.xcodeproj/**'] + pull_request: + paths: ['BalanceFlutter/**', '.github/workflows/flutter.yml', 'Balance/BalanceWatch/**', 'Balance/Balance.xcodeproj/**'] + workflow_dispatch: + +env: + FLUTTER_VERSION: '3.47.8' + +jobs: + checks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: BalanceFlutter + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + - run: flutter pub get + - run: flutter analyze + - run: flutter test + + build: + strategy: + fail-fast: false + matrix: + include: + - {platform: web, os: ubuntu-latest, artifact: 'BalanceFlutter/build/web'} + - {platform: android, os: ubuntu-latest, artifact: 'BalanceFlutter/build/app/outputs/flutter-apk/*.apk'} + - {platform: linux, os: ubuntu-latest, artifact: 'BalanceFlutter/build/linux/x64/release/bundle'} + - {platform: windows, os: windows-latest, artifact: 'BalanceFlutter/build/windows/x64/runner/Release'} + - {platform: ios, os: macos-latest, artifact: 'BalanceFlutter/build/ios/iphoneos/*.app'} + - {platform: macos, os: macos-latest, artifact: 'BalanceFlutter/build/macos/Build/Products/Release/*.app'} + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + working-directory: BalanceFlutter + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + if: matrix.platform == 'android' + with: + distribution: temurin + java-version: '17' + - name: Linux dependencies + if: matrix.platform == 'linux' + run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev libsecret-1-dev libjsoncpp-dev + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + - run: bash scripts/build.sh ${{ matrix.platform }} + - uses: actions/upload-artifact@v4 + with: + name: balance-${{ matrix.platform }} + path: ${{ matrix.artifact }} + if-no-files-found: error + + watchos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - run: bash BalanceFlutter/scripts/build.sh watchos + - uses: actions/upload-artifact@v4 + with: + name: balance-watchos + path: BalanceFlutter/build/watchos/Build/Products/*watchsimulator/*.app + if-no-files-found: error diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 58d0b89..1491c08 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,14 +2,9 @@ name: iOS CI on: push: - branches: - - main - - develop - - issue-* + branches: [ main ] pull_request: - branches: - - main - - develop + branches: [ main ] jobs: build-and-test: diff --git a/BalanceAndroid/gradlew b/BalanceAndroid/gradlew old mode 100644 new mode 100755 diff --git a/BalanceFlutter/FEATURE_PARITY.md b/BalanceFlutter/FEATURE_PARITY.md index 94dc90c..3fa3793 100644 --- a/BalanceFlutter/FEATURE_PARITY.md +++ b/BalanceFlutter/FEATURE_PARITY.md @@ -135,3 +135,7 @@ These checks do **not** prove actual Android OS wake-up, force-stop/reboot behav The web helper uses SDK `dart:js_interop` only. The audit compiled `scripts/probe_web_lock.dart` to JavaScript and ran it against Node 24's native `navigator.locks`: two separate owner instances serialized, ownership released/reacquired, duplicate acquire/release rejected, insecure origin rejected, unsupported API rejected. The existing three Android channel tests still pass, and targeted analyzer checks pass. This exercises native Web Locks Promise semantics, not a real browser two-tab IndexedDB/keychain scenario; that integration remains an explicit runtime check. The platform owner rebuilds the full release web bundle after this change. Desktop multiple independent app processes are not coordinated by this Web/Android helper. Standard desktop runners have one foreground AppStore; simultaneous desktop instances require a separately tested OS file-lock implementation before they can be claimed safe. + +## Appearance update 4.1 + +Flutter supports system/light/dark mode plus five persisted presets (forest, indigo, ocean, rose, OLED). Selecting OLED initially enables dark mode; system mode subsequently follows OS brightness. Pure-black surfaces are checked both as theme colors and actual rendered pixels. Total Flutter tests: 62 passing. The native Watch client retains its existing appearance controls. diff --git a/BalanceFlutter/README.md b/BalanceFlutter/README.md index 1b724a1..437003d 100644 --- a/BalanceFlutter/README.md +++ b/BalanceFlutter/README.md @@ -9,13 +9,18 @@ - Аналитика за 1, 3 и 12 календарных месяцев, доходы/расходы, результат, сбережения, средний расход, крупнейшая категория, динамика и структура расходов. - Месячные бюджеты по категориям, остаток и превышение; выбор месяца. - Встроенные и собственные категории, 50 значков Material с совместимыми серверными именами, 30 эмодзи и собственный эмодзи, 24 цвета. Переименование обновляет связанные записи; удаление сохраняет историю. -- RUB, EUR, USD, SEK; системная, светлая и тёмная тема; CSV-экспорт. +- RUB, EUR, USD, SEK; системная, светлая и тёмная тема; пресеты Лес, Индиго, Океан, Роза и OLED; CSV-экспорт. +- OLED использует чистый чёрный фон `#000000` в тёмном режиме (включая карточки, навигацию и диалоги). При выборе OLED включается тёмная тема; затем можно выбрать системный режим. Режим и пресет сохраняются локально. - Локальная работа без аккаунта; сервер, регистрация, вход, выход, refresh, ручная и автоматическая синхронизация, полная пересинхронизация. - Транзакционное сохранение данных и курсора, очередь изменений и удалений, повтор после сетевого сбоя; отдельные хранилища аккаунтов. - Адаптивная навигация: нижняя панель на телефоне, боковая на широком экране. ![Интерфейс на компьютере](docs/desktop.png) +OLED-пресет: + +![OLED настройки](docs/oled-settings.png) + ## Платформы | Платформа | Реализация / сборка | diff --git a/BalanceFlutter/VALIDATION.md b/BalanceFlutter/VALIDATION.md index 05b6406..a544c59 100644 --- a/BalanceFlutter/VALIDATION.md +++ b/BalanceFlutter/VALIDATION.md @@ -29,3 +29,11 @@ ## Принятые различия Material 3 и адаптивная навигация; денежные значения в целых сотых (старые доли менее сотой округляются); границы периодов `[start,end)`; отдельные данные аккаунтов; график динамики — пары столбцов. CloudKit удалён по прямому запросу. Финансовые payload, UUID, даты обновления, корректировки, категории, бюджеты и удаления совместимы с исходным сервером. + +## Обновление 4.1 — режимы и пресеты + +Добавлены Лес, Индиго, Океан, Роза и OLED. Режим (system/light/dark) и пресет сохраняются независимо в локальных настройках. При выборе OLED сначала включается dark; затем разрешено выбрать system. В тёмном OLED все основные Material surfaces, карточки, app bar, навигация и диалоги используют `#000000`, overlay tint отключён. + +Общий запуск после изменений: 62 Flutter-теста PASS; анализатор No issues found. Новые проверки: точные цвета OLED, разные акценты пресетов, сохранение/перезапуск, реакция системного режима на OS brightness, выбор на экране 320px и проверка чёрных пикселей снимков 390/1280px. + +APK 4.1 использует новый тестовый ключ, поскольку временный ключ предыдущей тестовой сборки не сохранился после смены среды. Прямая установка поверх APK 4.0 с другой подписью невозможна. Перед заменой старой тестовой сборки синхронизируйте её с Go-сервером и убедитесь в сохранности данных. Для последующих сборок ключ 4.1 сохранён отдельно от репозитория и исходного архива. Для публикации нужен собственный release keystore. diff --git a/BalanceFlutter/docs/oled-mobile.png b/BalanceFlutter/docs/oled-mobile.png new file mode 100644 index 0000000..3fa008c Binary files /dev/null and b/BalanceFlutter/docs/oled-mobile.png differ diff --git a/BalanceFlutter/docs/oled-settings.png b/BalanceFlutter/docs/oled-settings.png new file mode 100644 index 0000000..2778424 Binary files /dev/null and b/BalanceFlutter/docs/oled-settings.png differ diff --git a/BalanceFlutter/lib/domain/appearance.dart b/BalanceFlutter/lib/domain/appearance.dart new file mode 100644 index 0000000..fcc8f23 --- /dev/null +++ b/BalanceFlutter/lib/domain/appearance.dart @@ -0,0 +1,15 @@ +/// Device-local appearance preferences; these never enter finance sync payloads. +enum ColorPreset { + forest('forest', 'Лес', 0xff356859), + indigo('indigo', 'Индиго', 0xff4f46a5), + ocean('ocean', 'Океан', 0xff006b9a), + rose('rose', 'Роза', 0xff984563), + oled('oled', 'OLED — чёрный', 0xff51bda0); + + const ColorPreset(this.id, this.label, this.seedArgb); + final String id, label; + final int seedArgb; + + static ColorPreset fromId(String? id) => + values.firstWhere((preset) => preset.id == id, orElse: () => forest); +} diff --git a/BalanceFlutter/lib/services/app_store.dart b/BalanceFlutter/lib/services/app_store.dart index 353fdba..0061f40 100644 --- a/BalanceFlutter/lib/services/app_store.dart +++ b/BalanceFlutter/lib/services/app_store.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../domain/finance.dart'; +import '../domain/appearance.dart'; import 'storage.dart'; import 'runtime_lock.dart'; import 'sync_api.dart'; @@ -62,6 +63,7 @@ class AppStore extends ChangeNotifier with WidgetsBindingObserver { String serverUrl = ''; String currency = 'RUB'; String theme = 'system'; + String themePreset = 'forest'; AuthSession? session; String? sessionPersistenceWarning; String get email => session?.email ?? ''; @@ -121,6 +123,7 @@ class AppStore extends ChangeNotifier with WidgetsBindingObserver { serverUrl = _preferences!.getString('serverUrl') ?? serverUrl; currency = _preferences!.getString('currency') ?? currency; theme = _preferences!.getString('theme') ?? theme; + themePreset = ColorPreset.fromId(_preferences!.getString('themePreset')).id; _deviceId = _preferences!.getString('deviceId') ?? newId(); await _preferences!.setString('deviceId', _deviceId); _storage ??= await LedgerStorage.open(); @@ -147,6 +150,7 @@ class AppStore extends ChangeNotifier with WidgetsBindingObserver { } currency = _preferences!.getString('currency') ?? 'RUB'; theme = _preferences!.getString('theme') ?? 'system'; + themePreset = ColorPreset.fromId(_preferences!.getString('themePreset')).id; _deviceId = _preferences!.getString('deviceId') ?? _deviceId; await _restoreSession(); await _loadScope(); @@ -481,10 +485,26 @@ class AppStore extends ChangeNotifier with WidgetsBindingObserver { _notify(); }); Future setTheme(String value) => _exclusive(() async { + if (!const ['system', 'light', 'dark'].contains(value)) { + throw ArgumentError('Неизвестный режим темы'); + } theme = value; await _preferences!.setString('theme', value); _notify(); }); + Future setThemePreset(String value) => _exclusive(() async { + if (!ColorPreset.values.any((preset) => preset.id == value)) { + throw ArgumentError('Неизвестный пресет'); + } + await _preferences!.setString('themePreset', value); + themePreset = value; + if (value == ColorPreset.oled.id) { + await _preferences!.setString('theme', 'dark'); + theme = 'dark'; + } + _notify(); + }); + Future setServerUrl(String value) => _exclusive(() async { final normalized = value.trim().replaceAll(RegExp(r'/+$'), ''); final uri = Uri.tryParse(normalized); diff --git a/BalanceFlutter/lib/ui/app.dart b/BalanceFlutter/lib/ui/app.dart index c5a4ee2..dc8569f 100644 --- a/BalanceFlutter/lib/ui/app.dart +++ b/BalanceFlutter/lib/ui/app.dart @@ -6,6 +6,8 @@ import 'package:intl/intl.dart'; import 'package:file_selector/file_selector.dart'; import 'package:share_plus/share_plus.dart'; import '../domain/finance.dart'; +import '../domain/appearance.dart'; +import 'app_theme.dart'; import '../services/app_store.dart'; import 'design.dart'; import 'editors.dart'; @@ -22,8 +24,14 @@ class BalanceApp extends StatelessWidget { locale: const Locale('ru'), supportedLocales: const [Locale('ru'), Locale('en')], localizationsDelegates: GlobalMaterialLocalizations.delegates, - theme: makeTheme(Brightness.light), - darkTheme: makeTheme(Brightness.dark), + theme: BalanceThemes.build( + Brightness.light, + ColorPreset.fromId(store.themePreset), + ), + darkTheme: BalanceThemes.build( + Brightness.dark, + ColorPreset.fromId(store.themePreset), + ), themeMode: store.theme == 'dark' ? ThemeMode.dark : store.theme == 'light' @@ -34,28 +42,6 @@ class BalanceApp extends StatelessWidget { : const Scaffold(body: Center(child: CircularProgressIndicator())), ), ); - ThemeData makeTheme(Brightness brightness) { - final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xff356859), - brightness: brightness, - ); - return ThemeData( - useMaterial3: true, - fontFamily: 'BalanceRoboto', - colorScheme: scheme, - scaffoldBackgroundColor: scheme.surface, - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - ), - cardTheme: CardThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - ), - appBarTheme: AppBarTheme( - backgroundColor: scheme.surface, - centerTitle: false, - ), - ); - } } class BalanceShell extends StatefulWidget { @@ -1105,6 +1091,7 @@ class _BalanceShellState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( + key: ValueKey('theme-mode-${store.theme}'), initialValue: store.theme, decoration: const InputDecoration(labelText: 'Тема'), items: const [ @@ -1116,6 +1103,45 @@ class _BalanceShellState extends State { if (v != null) run(() => store.setTheme(v)); }, ), + const SizedBox(height: 20), + Text( + 'Цветовой пресет', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: ColorPreset.values + .map( + (preset) => ChoiceChip( + label: Text(preset.label), + avatar: CircleAvatar( + radius: 9, + backgroundColor: preset == ColorPreset.oled + ? Colors.black + : Color(preset.seedArgb), + child: preset == ColorPreset.oled + ? const Icon( + Icons.nightlight_round, + size: 13, + color: Colors.white, + ) + : null, + ), + selected: store.themePreset == preset.id, + onSelected: (_) => + run(() => store.setThemePreset(preset.id)), + ), + ) + .toList(), + ), + if (store.themePreset == 'oled') ...[ + const SizedBox(height: 12), + const Text( + 'OLED: чистый чёрный фон #000000 в тёмном режиме. При выборе пресета тёмная тема включается автоматически. Можно затем выбрать системный режим.', + ), + ], const SizedBox(height: 12), Text( 'Валюта задаёт формат сумм. Конвертация валют не выполняется.', @@ -1288,7 +1314,7 @@ class _BalanceShellState extends State { onPressed: () => showAboutDialog( context: context, applicationName: 'Баланс', - applicationVersion: '4.0.0', + applicationVersion: '4.1.0', applicationIcon: const Icon( Icons.account_balance_wallet_outlined, size: 40, diff --git a/BalanceFlutter/lib/ui/app_theme.dart b/BalanceFlutter/lib/ui/app_theme.dart new file mode 100644 index 0000000..eb69520 --- /dev/null +++ b/BalanceFlutter/lib/ui/app_theme.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import '../domain/appearance.dart'; + +class BalanceThemes { + static ThemeData build(Brightness brightness, ColorPreset preset) { + final oled = brightness == Brightness.dark && preset == ColorPreset.oled; + var scheme = ColorScheme.fromSeed( + seedColor: Color(preset.seedArgb), + brightness: brightness, + ); + if (oled) { + scheme = scheme.copyWith( + surface: Colors.black, + surfaceDim: Colors.black, + surfaceBright: Colors.black, + surfaceContainerLowest: Colors.black, + surfaceContainerLow: Colors.black, + surfaceContainer: Colors.black, + surfaceContainerHigh: Colors.black, + surfaceContainerHighest: Colors.black, + surfaceTint: Colors.transparent, + primaryContainer: Colors.black, + onPrimaryContainer: scheme.onSurface, + secondaryContainer: const Color(0xff171b19), + outlineVariant: const Color(0xff303633), + ); + } + return ThemeData( + useMaterial3: true, + fontFamily: 'BalanceRoboto', + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + canvasColor: scheme.surface, + applyElevationOverlayColor: !oled, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + cardTheme: CardThemeData( + surfaceTintColor: oled ? Colors.transparent : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: oled + ? BorderSide(color: scheme.outlineVariant) + : BorderSide.none, + ), + ), + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + surfaceTintColor: oled ? Colors.transparent : null, + centerTitle: false, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: oled ? Colors.black : null, + surfaceTintColor: oled ? Colors.transparent : null, + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: oled ? Colors.black : null, + ), + dialogTheme: DialogThemeData( + backgroundColor: oled ? Colors.black : null, + surfaceTintColor: oled ? Colors.transparent : null, + ), + ); + } +} diff --git a/BalanceFlutter/pubspec.yaml b/BalanceFlutter/pubspec.yaml index b3353da..6838a65 100644 --- a/BalanceFlutter/pubspec.yaml +++ b/BalanceFlutter/pubspec.yaml @@ -1,7 +1,7 @@ name: balance_flutter description: Local-first personal finance with BalanceServer synchronization. publish_to: none -version: 4.0.0+1 +version: 4.1.0+2 environment: sdk: '>=3.8.0 <4.0.0' dependencies: diff --git a/BalanceFlutter/test/appearance_test.dart b/BalanceFlutter/test/appearance_test.dart new file mode 100644 index 0000000..a045a5d --- /dev/null +++ b/BalanceFlutter/test/appearance_test.dart @@ -0,0 +1,140 @@ +import 'package:balance_flutter/domain/appearance.dart'; +import 'package:balance_flutter/services/app_store.dart'; +import 'package:balance_flutter/services/storage.dart'; +import 'package:balance_flutter/ui/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'widget_test.dart' as fixtures; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(fixtures.mockStorage); + + test('OLED uses pure black for surfaces, cards, navigation and dialogs', () { + final theme = BalanceThemes.build(Brightness.dark, ColorPreset.oled); + final scheme = theme.colorScheme; + expect(theme.brightness, Brightness.dark); + for (final surface in [ + theme.scaffoldBackgroundColor, + theme.canvasColor, + scheme.surface, + scheme.surfaceDim, + scheme.surfaceBright, + scheme.surfaceContainerLowest, + scheme.surfaceContainerLow, + scheme.surfaceContainer, + scheme.surfaceContainerHigh, + scheme.surfaceContainerHighest, + scheme.primaryContainer, + theme.appBarTheme.backgroundColor, + theme.navigationBarTheme.backgroundColor, + theme.navigationRailTheme.backgroundColor, + theme.dialogTheme.backgroundColor, + ]) { + expect(surface, Colors.black); + } + expect(theme.cardTheme.surfaceTintColor, Colors.transparent); + expect(theme.applyElevationOverlayColor, isFalse); + expect(scheme.onSurface.computeLuminance(), greaterThan(.6)); + expect(scheme.primary.computeLuminance(), greaterThan(.3)); + }); + + test('presets have distinct accents and preserve light/dark mode', () { + final accents = {}; + for (final preset in ColorPreset.values) { + final light = BalanceThemes.build(Brightness.light, preset); + final dark = BalanceThemes.build(Brightness.dark, preset); + expect(light.brightness, Brightness.light); + expect(dark.brightness, Brightness.dark); + expect(light.scaffoldBackgroundColor, isNot(Colors.black)); + accents.add(light.colorScheme.primary); + } + expect(accents.length, ColorPreset.values.length); + }); + + test( + 'preset and mode survive restart; OLED initially selects dark', + () async { + final storage = MemorySnapshotStorage(); + final store = AppStore(storage: storage, background: true); + await store.initialize(); + expect(store.themePreset, 'forest'); + expect(store.theme, 'system'); + await store.setTheme('light'); + await store.setThemePreset('oled'); + expect(store.theme, 'dark'); + await store.setTheme('system'); + final reopened = AppStore(storage: storage, background: true); + await reopened.initialize(); + expect(reopened.themePreset, 'oled'); + expect(reopened.theme, 'system'); + await reopened.setThemePreset('rose'); + expect(reopened.theme, 'system'); + await expectLater( + reopened.setThemePreset('unknown'), + throwsArgumentError, + ); + await store.close(); + await reopened.close(); + store.dispose(); + reopened.dispose(); + }, + ); + + testWidgets('system mode responds to OS brightness with OLED selected', ( + tester, + ) async { + final store = (await tester.runAsync( + () => fixtures.createStore(seeded: false), + ))!; + await tester.runAsync(() async { + await store.setThemePreset('oled'); + await store.setTheme('system'); + }); + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + await fixtures.boot(tester, store, const Size(390, 844)); + ThemeData theme() => Theme.of(tester.element(find.byType(Scaffold).first)); + expect(theme().scaffoldBackgroundColor, Colors.black); + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + await tester.pumpAndSettle(); + expect(theme().brightness, Brightness.light); + expect(theme().scaffoldBackgroundColor, isNot(Colors.black)); + await tester.runAsync(() => store.setTheme('dark')); + await tester.pumpAndSettle(); + expect(theme().scaffoldBackgroundColor, Colors.black); + }); + + testWidgets( + 'OLED can be selected in narrow settings and updates mode control', + (tester) async { + await fixtures.loadReadableFont(); + final store = (await tester.runAsync( + () => fixtures.createStore(seeded: false), + ))!; + await fixtures.boot(tester, store, const Size(320, 568)); + await fixtures.navigate(tester, 4); + final oled = find.widgetWithText(ChoiceChip, 'OLED — чёрный'); + await tester.ensureVisible(oled); + await tester.pumpAndSettle(); + await tester.tap(oled); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); + expect(store.themePreset, 'oled'); + expect(store.theme, 'dark'); + expect( + tester.widget(find.byType(MaterialApp)).themeMode, + ThemeMode.dark, + ); + expect( + Theme.of( + tester.element(find.byType(Scaffold).first), + ).scaffoldBackgroundColor, + Colors.black, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/BalanceFlutter/test/oled_preview_test.dart b/BalanceFlutter/test/oled_preview_test.dart new file mode 100644 index 0000000..2280ffd --- /dev/null +++ b/BalanceFlutter/test/oled_preview_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'widget_test.dart' as fixtures; + +void main() { + setUpAll(fixtures.loadReadableFont); + setUp(fixtures.mockStorage); + for (final size in [const Size(390, 844), const Size(1280, 900)]) { + testWidgets('OLED renders black pixels at $size', (tester) async { + final store = (await tester.runAsync(() => fixtures.createStore()))!; + await tester.runAsync(() => store.setThemePreset('oled')); + final key = GlobalKey(); + await fixtures.boot(tester, store, size, boundaryKey: key); + for (final page in [0, 4]) { + await fixtures.navigate(tester, page); + await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1); + final rgba = await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + // Left edge of the scaffold/rail below the app bar, away from text. + final pixel = (110 * image.width) * 4; + expect(rgba!.getUint8(pixel), 0); + expect(rgba.getUint8(pixel + 1), 0); + expect(rgba.getUint8(pixel + 2), 0); + expect(rgba.getUint8(pixel + 3), 255); + final png = await image.toByteData(format: ui.ImageByteFormat.png); + final dir = Directory('/tmp/balance-oled-preview') + ..createSync(recursive: true); + File( + '${dir.path}/${size.width.toInt()}-${page == 0 ? 'overview' : 'settings'}.png', + ).writeAsBytesSync(png!.buffer.asUint8List()); + image.dispose(); + }); + expect(tester.takeException(), isNull); + } + }); + } +}