Merge Phase 6: localization finalize + brand icon + juice + store assets
Brand app icon (navy 2x2 glossy block mark, vector-drawn, no AI image) applied on iOS+Android via flutter_launcher_icons. Sound & vibration toggle (themed Settings). Juice: button press feedback + fade screen transitions. Play feature graphic (Titan One wordmark). l10n EN/KO verified (no hardcoded strings, key parity). 176 tests green; analyze clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,8 @@
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/icon/icon.png"
|
||||
remove_alpha_ios: true
|
||||
min_sdk_android: 21
|
||||
adaptive_icon_background: "assets/icon/icon_background.png"
|
||||
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
|
||||
@@ -562,7 +562,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -619,7 +619,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 601 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 960 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.6 KiB |
@@ -42,6 +42,9 @@ class SaveRepository {
|
||||
_adsRemoved =
|
||||
(json['flags'] as Map<String, dynamic>?)?['adsRemoved'] as bool? ??
|
||||
false;
|
||||
_soundEnabled =
|
||||
(json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
|
||||
true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +59,13 @@ class SaveRepository {
|
||||
bool _tutorialDone = false;
|
||||
int _endlessBest = 0;
|
||||
bool _adsRemoved = false;
|
||||
bool _soundEnabled = true;
|
||||
|
||||
StreakState get streak => _streak;
|
||||
bool get tutorialDone => _tutorialDone;
|
||||
int get endlessBest => _endlessBest;
|
||||
bool get adsRemoved => _adsRemoved;
|
||||
bool get soundEnabled => _soundEnabled;
|
||||
|
||||
Future<void> markTutorialDone() {
|
||||
_tutorialDone = true;
|
||||
@@ -72,6 +77,11 @@ class SaveRepository {
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> setSoundEnabled(bool value) {
|
||||
_soundEnabled = value;
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> recordEndlessScore(int score) {
|
||||
if (score > _endlessBest) _endlessBest = score;
|
||||
return _flush();
|
||||
@@ -140,7 +150,11 @@ class SaveRepository {
|
||||
'best': _streak.best,
|
||||
'lastYmd': _streak.lastYmd,
|
||||
},
|
||||
'flags': {'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved},
|
||||
'flags': {
|
||||
'tutorialDone': _tutorialDone,
|
||||
'adsRemoved': _adsRemoved,
|
||||
'soundEnabled': _soundEnabled,
|
||||
},
|
||||
'endless': {'best': _endlessBest},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -59,5 +59,6 @@
|
||||
"removeAdsDescription": "Removes banners and full-screen ads. Reward ads stay available.",
|
||||
"restorePurchases": "Restore purchases",
|
||||
"adsRemovedThanks": "Ads removed — thank you!",
|
||||
"purchaseUnavailable": "Purchases are unavailable right now."
|
||||
"purchaseUnavailable": "Purchases are unavailable right now.",
|
||||
"soundAndVibration": "Sound & vibration"
|
||||
}
|
||||
|
||||
@@ -31,5 +31,6 @@
|
||||
"removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.",
|
||||
"restorePurchases": "구매 복원",
|
||||
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
|
||||
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다."
|
||||
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
|
||||
"soundAndVibration": "소리 및 진동"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../services/consent_service.dart';
|
||||
import '../services/iap_service.dart';
|
||||
import 'ads_notifier.dart';
|
||||
import 'endless_best_notifier.dart';
|
||||
import 'sound_notifier.dart';
|
||||
import 'game_session_notifier.dart';
|
||||
import 'progress_notifier.dart';
|
||||
import 'season_flow_notifier.dart';
|
||||
@@ -22,8 +23,12 @@ final gameSessionProvider =
|
||||
GameSessionNotifier.new,
|
||||
);
|
||||
|
||||
final soundEnabledProvider =
|
||||
NotifierProvider<SoundEnabledNotifier, bool>(SoundEnabledNotifier.new);
|
||||
|
||||
final audioServiceProvider = Provider<AudioService>((ref) {
|
||||
final service = AudioService();
|
||||
final service = AudioService(enabled: ref.read(soundEnabledProvider));
|
||||
ref.listen<bool>(soundEnabledProvider, (_, next) => service.enabled = next);
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'providers.dart';
|
||||
|
||||
/// SFX + gameplay haptics on/off, seeded from the save repository.
|
||||
class SoundEnabledNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => ref.read(saveRepositoryProvider).soundEnabled;
|
||||
|
||||
Future<void> toggle() => set(!state);
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
if (state == value) return;
|
||||
await ref.read(saveRepositoryProvider).setSoundEnabled(value);
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// lib/ui/branding/app_icon_painter.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/tile_painter.dart';
|
||||
|
||||
/// Draws the Block Seasons brand mark: deep-navy field + a 2×2 grid of glossy
|
||||
/// brand-color blocks. Shared by the launcher-icon and feature-graphic
|
||||
/// generators so the brand stays identical everywhere.
|
||||
class AppIconMark {
|
||||
static const navy = [Color(0xFF101736), Color(0xFF192555), Color(0xFF2C3168)];
|
||||
static const pink = Color(0xFFFF7EB3);
|
||||
static const yellow = Color(0xFFFFD166);
|
||||
static const cyan = Color(0xFF6FCDF5);
|
||||
static const green = Color(0xFF7EDB9C);
|
||||
|
||||
/// Fills [rect] with the navy gradient.
|
||||
static void paintBackground(Canvas canvas, Rect rect) {
|
||||
final paint = Paint()
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: navy,
|
||||
).createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
/// Paints the 2×2 glossy blocks centered in a square of side [size], the
|
||||
/// block group occupying [groupFraction] of the side.
|
||||
static void paintBlocks(Canvas canvas, double size,
|
||||
{double groupFraction = 0.6}) {
|
||||
final group = size * groupFraction;
|
||||
final gap = size * 0.05;
|
||||
final block = (group - gap) / 2;
|
||||
final m = (size - group) / 2;
|
||||
final far = m + block + gap;
|
||||
void tile(double x, double y, Color c) => paintGlossyTile(
|
||||
canvas, Rect.fromLTWH(x, y, block, block), c, radiusFactor: 0.24);
|
||||
tile(m, m, pink);
|
||||
tile(far, m, yellow);
|
||||
tile(m, far, cyan);
|
||||
tile(far, far, green);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// lib/ui/branding/feature_graphic_painter.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_icon_painter.dart';
|
||||
|
||||
/// Paints the Play feature graphic (1024×500): navy field, the brand blocks on
|
||||
/// the left, wordmark + tagline on the right.
|
||||
class FeatureGraphic {
|
||||
static void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
AppIconMark.paintBackground(canvas, rect);
|
||||
|
||||
// Blocks on the left, vertically centered.
|
||||
canvas.save();
|
||||
final blockArea = size.height * 0.74;
|
||||
canvas.translate(size.height * 0.16, (size.height - blockArea) / 2);
|
||||
AppIconMark.paintBlocks(canvas, blockArea, groupFraction: 0.92);
|
||||
canvas.restore();
|
||||
|
||||
// Text column begins just right of the blocks; kept within the 1024 width.
|
||||
final textLeft = size.height * 0.94;
|
||||
void text(String s, double dy, double fontSize, Color c) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: s,
|
||||
style: TextStyle(
|
||||
color: c,
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'TitanOne',
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: size.width - textLeft - size.height * 0.06);
|
||||
tp.paint(canvas, Offset(textLeft, dy));
|
||||
}
|
||||
|
||||
text('Block Seasons', size.height * 0.33, 56, Colors.white);
|
||||
text('A new season of blocks,\nevery few weeks.', size.height * 0.56, 22,
|
||||
const Color(0xFFB9C4E6));
|
||||
}
|
||||
}
|
||||
@@ -120,16 +120,17 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
final audio = ref.read(audioServiceProvider);
|
||||
if (prev?.fxTick != next.fxTick && next.lastPlacement != null) {
|
||||
final placement = next.lastPlacement!;
|
||||
final hapticsOn = ref.read(soundEnabledProvider);
|
||||
if (placement.linesCleared > 0) {
|
||||
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
|
||||
HapticFeedback.mediumImpact();
|
||||
if (hapticsOn) HapticFeedback.mediumImpact();
|
||||
if (placement.comboStreak >= 4) {
|
||||
HapticFeedback.heavyImpact();
|
||||
if (hapticsOn) HapticFeedback.heavyImpact();
|
||||
_shake.forward(from: 0);
|
||||
}
|
||||
} else {
|
||||
audio.play(Sfx.place);
|
||||
HapticFeedback.lightImpact();
|
||||
if (hapticsOn) HapticFeedback.lightImpact();
|
||||
}
|
||||
ref.read(tutorialProvider.notifier).onPlaced();
|
||||
if (placement.linesCleared > 0) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import '../../game/models/stage.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../widgets/banner_ad_slot.dart';
|
||||
import '../widgets/fade_route.dart';
|
||||
import '../widgets/pressable_scale.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
import 'game_screen.dart';
|
||||
import 'season_map_screen.dart';
|
||||
@@ -67,43 +69,45 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 44),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 56, vertical: 18),
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
PressableScale(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 56, vertical: 18),
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
onPressed: () {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||
Navigator.of(context).push(
|
||||
fadeRoute(const SeasonMapScreen()),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.adventure),
|
||||
),
|
||||
onPressed: () {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SeasonMapScreen()),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.adventure),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40, vertical: 14),
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
PressableScale(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40, vertical: 14),
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
onPressed: () {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||
ref.read(seasonFlowProvider.notifier).clear();
|
||||
ref.read(analyticsProvider).endlessStart();
|
||||
ref.read(gameSessionProvider.notifier).startStage(
|
||||
StageConfig.endless(
|
||||
seed: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
fadeRoute(const GameScreen()),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.classic),
|
||||
),
|
||||
onPressed: () {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||
ref.read(seasonFlowProvider.notifier).clear();
|
||||
ref.read(analyticsProvider).endlessStart();
|
||||
ref.read(gameSessionProvider.notifier).startStage(
|
||||
StageConfig.endless(
|
||||
seed: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const GameScreen()),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.classic),
|
||||
),
|
||||
if (best > 0) ...[
|
||||
const SizedBox(height: 10),
|
||||
@@ -127,7 +131,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings, color: Colors.white70),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
fadeRoute(const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,9 @@ import '../../game/models/season.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../theme/palette.dart';
|
||||
import '../widgets/banner_ad_slot.dart';
|
||||
import '../widgets/fade_route.dart';
|
||||
import '../widgets/map_layout.dart';
|
||||
import '../widgets/pressable_scale.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
import '../widgets/tile_painter.dart';
|
||||
import 'game_screen.dart';
|
||||
@@ -193,79 +195,81 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> {
|
||||
key: Key('stage_node_$i'),
|
||||
left: center.dx - size / 2,
|
||||
top: center.dy - size / 2,
|
||||
child: GestureDetector(
|
||||
onTap: !isUnlocked
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(seasonFlowProvider.notifier)
|
||||
.startSeasonStage(widget.pack, i);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GameScreen()),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: isUnlocked
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isCurrent
|
||||
? [
|
||||
lighten(colors.accent, 0.25),
|
||||
colors.accent,
|
||||
darken(colors.accent, 0.2),
|
||||
]
|
||||
: [
|
||||
const Color(0xFFFFE9A8),
|
||||
const Color(0xFFFFD166),
|
||||
const Color(0xFFE0AC3B),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: isUnlocked ? null : GamePalette.lockedNode,
|
||||
boxShadow: isCurrent
|
||||
? [
|
||||
BoxShadow(
|
||||
color: colors.accent.withValues(alpha: 0.7),
|
||||
blurRadius: 22,
|
||||
child: PressableScale(
|
||||
child: GestureDetector(
|
||||
onTap: !isUnlocked
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(seasonFlowProvider.notifier)
|
||||
.startSeasonStage(widget.pack, i);
|
||||
Navigator.of(context).push(
|
||||
fadeRoute(const GameScreen()),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: isUnlocked
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isCurrent
|
||||
? [
|
||||
lighten(colors.accent, 0.25),
|
||||
colors.accent,
|
||||
darken(colors.accent, 0.2),
|
||||
]
|
||||
: [
|
||||
const Color(0xFFFFE9A8),
|
||||
const Color(0xFFFFD166),
|
||||
const Color(0xFFE0AC3B),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: isUnlocked ? null : GamePalette.lockedNode,
|
||||
boxShadow: isCurrent
|
||||
? [
|
||||
BoxShadow(
|
||||
color: colors.accent.withValues(alpha: 0.7),
|
||||
blurRadius: 22,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isUnlocked
|
||||
? Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: isCurrent ? 22 : 17,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isCurrent
|
||||
? Colors.white
|
||||
: const Color(0xFF5A4200),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
)
|
||||
: const Icon(Icons.lock, color: Colors.white24, size: 20),
|
||||
),
|
||||
child: isUnlocked
|
||||
? Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: isCurrent ? 22 : 17,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isCurrent
|
||||
? Colors.white
|
||||
: const Color(0xFF5A4200),
|
||||
if (isUnlocked && !isCurrent)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var s = 0; s < 3; s++)
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 13,
|
||||
color: s < stars ? Colors.amber : Colors.white24,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lock, color: Colors.white24, size: 20),
|
||||
),
|
||||
if (isUnlocked && !isCurrent)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var s = 0; s < 3; s++)
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 13,
|
||||
color: s < stars ? Colors.amber : Colors.white24,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../game/models/season.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -12,6 +14,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final adsRemoved = ref.watch(adsRemovedProvider);
|
||||
final soundOn = ref.watch(soundEnabledProvider);
|
||||
final iap = ref.read(iapServiceProvider);
|
||||
|
||||
ref.listen<bool>(adsRemovedProvider, (prev, next) {
|
||||
@@ -22,37 +25,54 @@ class SettingsScreen extends ConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(l10n.removeAds),
|
||||
subtitle: Text(l10n.removeAdsDescription),
|
||||
trailing: adsRemoved
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: Text(iap.product?.price ?? ''),
|
||||
onTap: adsRemoved
|
||||
? null
|
||||
: () async {
|
||||
if (!iap.available || iap.product == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.purchaseUnavailable)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await iap.buyRemoveAds();
|
||||
},
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const SeasonBackground(theme: SeasonTheme.fallback),
|
||||
Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
title: Text(l10n.settings),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.restore),
|
||||
title: Text(l10n.restorePurchases),
|
||||
onTap: () => iap.restorePurchases(),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(l10n.soundAndVibration),
|
||||
value: soundOn,
|
||||
onChanged: (v) =>
|
||||
ref.read(soundEnabledProvider.notifier).set(v),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(l10n.removeAds),
|
||||
subtitle: Text(l10n.removeAdsDescription),
|
||||
trailing: adsRemoved
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: Text(iap.product?.price ?? ''),
|
||||
onTap: adsRemoved
|
||||
? null
|
||||
: () async {
|
||||
if (!iap.available || iap.product == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.purchaseUnavailable)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await iap.buyRemoveAds();
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.restore),
|
||||
title: Text(l10n.restorePurchases),
|
||||
onTap: () => iap.restorePurchases(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// lib/ui/widgets/fade_route.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A gentle fade(+slight scale) page transition for in-app navigation.
|
||||
Route<T> fadeRoute<T>(Widget page) {
|
||||
return PageRouteBuilder<T>(
|
||||
transitionDuration: const Duration(milliseconds: 320),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||
pageBuilder: (_, _, _) => page,
|
||||
transitionsBuilder: (_, animation, _, child) {
|
||||
final curved =
|
||||
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.98, end: 1.0).animate(curved),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// lib/ui/widgets/pressable_scale.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wraps a tappable child with a quick scale-down on press for tactile feel.
|
||||
/// If [onTap] is provided it handles the tap; otherwise the child's own
|
||||
/// gesture/button handles it and this only adds the visual squish.
|
||||
class PressableScale extends StatefulWidget {
|
||||
const PressableScale({super.key, required this.child, this.onTap});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<PressableScale> createState() => _PressableScaleState();
|
||||
}
|
||||
|
||||
class _PressableScaleState extends State<PressableScale> {
|
||||
bool _down = false;
|
||||
void _set(bool v) => setState(() => _down = v);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _set(true),
|
||||
onTapUp: (_) => _set(false),
|
||||
onTapCancel: () => _set(false),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedScale(
|
||||
scale: _down ? 0.94 : 1.0,
|
||||
duration: const Duration(milliseconds: 90),
|
||||
curve: Curves.easeOut,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -121,6 +129,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -129,6 +145,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -262,6 +286,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -341,6 +373,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
in_app_purchase:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -549,6 +589,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -573,6 +621,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -890,6 +946,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -58,6 +58,7 @@ dev_dependencies:
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('soundEnabled defaults true and persists across reopen', () async {
|
||||
final repo = await SaveRepository.open();
|
||||
expect(repo.soundEnabled, isTrue);
|
||||
await repo.setSoundEnabled(false);
|
||||
expect(repo.soundEnabled, isFalse);
|
||||
final reopened = await SaveRepository.open();
|
||||
expect(reopened.soundEnabled, isFalse);
|
||||
});
|
||||
|
||||
test('legacy save without the sound flag reads as true', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'save_v1': '{"saveVersion":1,"progress":{},"flags":{"tutorialDone":true}}',
|
||||
});
|
||||
final repo = await SaveRepository.open();
|
||||
expect(repo.soundEnabled, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/state/providers.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test('reads persisted sound flag and toggles + persists', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repo = await SaveRepository.open();
|
||||
final c = ProviderContainer(
|
||||
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
|
||||
);
|
||||
addTearDown(c.dispose);
|
||||
|
||||
expect(c.read(soundEnabledProvider), isTrue);
|
||||
await c.read(soundEnabledProvider.notifier).toggle();
|
||||
expect(c.read(soundEnabledProvider), isFalse);
|
||||
expect(repo.soundEnabled, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// test/tool/generate_brand_assets_test.dart
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:block_seasons/ui/branding/app_icon_painter.dart';
|
||||
import 'package:block_seasons/ui/branding/feature_graphic_painter.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<void> _writePng(String path, int size, void Function(Canvas) draw) async {
|
||||
final recorder = PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
draw(canvas);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(size, size);
|
||||
final bytes = await image.toByteData(format: ImageByteFormat.png);
|
||||
File(path).parent.createSync(recursive: true);
|
||||
File(path).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('generate launcher icon PNGs', (tester) async {
|
||||
const s = 1024;
|
||||
final full = Rect.fromLTWH(0, 0, s.toDouble(), s.toDouble());
|
||||
|
||||
await tester.runAsync(() async {
|
||||
// Master (iOS + fallback): opaque navy + blocks at 60%.
|
||||
await _writePng('assets/icon/icon.png', s, (c) {
|
||||
AppIconMark.paintBackground(c, full);
|
||||
AppIconMark.paintBlocks(c, s.toDouble(), groupFraction: 0.6);
|
||||
});
|
||||
// Adaptive background: navy only.
|
||||
await _writePng('assets/icon/icon_background.png', s, (c) {
|
||||
AppIconMark.paintBackground(c, full);
|
||||
});
|
||||
// Adaptive foreground: blocks only (transparent), 52% for the safe zone.
|
||||
await _writePng('assets/icon/icon_foreground.png', s, (c) {
|
||||
AppIconMark.paintBlocks(c, s.toDouble(), groupFraction: 0.52);
|
||||
});
|
||||
});
|
||||
|
||||
for (final f in ['icon.png', 'icon_background.png', 'icon_foreground.png']) {
|
||||
expect(File('assets/icon/$f').existsSync(), isTrue, reason: f);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('generate feature graphic', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
// Load the real display font (OFL Titan One) so the wordmark renders as
|
||||
// glyphs — the flutter_test default font draws every char as a box.
|
||||
final fontBytes =
|
||||
File('tool/fonts/TitanOne-Regular.ttf').readAsBytesSync();
|
||||
final loader = FontLoader('TitanOne')
|
||||
..addFont(Future.value(ByteData.view(fontBytes.buffer)));
|
||||
await loader.load();
|
||||
|
||||
const w = 1024, h = 500;
|
||||
final recorder = PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
FeatureGraphic.paint(canvas, const Size(1024, 500));
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(w, h);
|
||||
final bytes = await image.toByteData(format: ImageByteFormat.png);
|
||||
File('docs/store/feature_graphic.png').parent.createSync(recursive: true);
|
||||
File('docs/store/feature_graphic.png')
|
||||
.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
});
|
||||
expect(File('docs/store/feature_graphic.png').existsSync(), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:block_seasons/ui/widgets/pressable_scale.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('inner button onPressed still fires when wrapped', (tester) async {
|
||||
var taps = 0;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: PressableScale(
|
||||
child: FilledButton(
|
||||
onPressed: () => taps++,
|
||||
child: const Text('Play'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.text('Play'));
|
||||
await tester.pump();
|
||||
expect(taps, 1, reason: 'PressableScale must not swallow the inner tap');
|
||||
});
|
||||
|
||||
testWidgets('own onTap fires when no inner handler', (tester) async {
|
||||
var taps = 0;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: PressableScale(
|
||||
onTap: () => taps++,
|
||||
child: const SizedBox(width: 100, height: 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
await tester.tap(find.byType(PressableScale));
|
||||
await tester.pump();
|
||||
expect(taps, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2011, Rodrigo Fuenzalida (www.rfuenzalida.com|hello@rfuenzalida.com),
|
||||
with Reserved Font Name Titan.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||