roy_casual_kit 0.4.3 copy "roy_casual_kit: ^0.4.3" to clipboard
roy_casual_kit: ^0.4.3 copied to clipboard

A casual/idle-game SDK for Flutter (GetX + Flame): storage, i18n, audio, haptics, reminders, economy/progression, live-ops tooling, and a candy-styled widget kit.

example/lib/main.dart

import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'package:roy_casual_kit/roy_casual_kit.dart';

import 'screens/home_screen.dart';

void main() => runZonedGuarded(() => app(), reportUncaughtError);

/// Shared sink for every runtime error this app can catch — the
/// `runZonedGuarded` zone error callback below (async errors: timers,
/// stream listeners, unawaited futures) AND `FlutterError.onError` (framework
/// build/layout/paint errors), set inside [app]. Forwards to
/// `CrashReporter.maybe` so nothing is silent in a release build (`dlog()`
/// itself no-ops there). No-op if the consuming app hasn't registered a
/// `CrashReporter` implementation.
void reportUncaughtError(Object error, StackTrace stack) {
  CrashReporter.maybe?.recordError(error, stack);
}

/// Overrides `FlutterError.onError` to forward framework (build/layout/
/// paint) errors into [reportUncaughtError] too, chaining to whatever
/// handler was already set (test bindings install their own). Split out
/// from [app] so it's testable without that function's platform-channel-
/// heavy init (SharedPreferences, PackageInfo, Wakelock — none of which
/// have real handlers in a plain `flutter test`, only on a real device/
/// integration_test).
void installErrorHandlers() {
  final previousOnError = FlutterError.onError;
  FlutterError.onError = (details) {
    reportUncaughtError(details.exception, details.stack ?? StackTrace.empty);
    (previousOnError ?? FlutterError.presentError)(details);
  };
}

/// Điểm khởi chạy app (tách riêng để integration_test gọi lại được).
/// [withAudio] mặc định `!isE2eTest`: audioplayers đăng ký frame callback
/// liên tục, gây lỗi "animation still running" lúc teardown trong device
/// test tự động (`--dart-define=E2E_TEST=true`) — vẫn có thể override thủ
/// công khi gọi `app()` trực tiếp.
Future<void> app({bool withAudio = !isE2eTest}) async {
  dlog('app: ensureInitialized');
  WidgetsFlutterBinding.ensureInitialized();
  installErrorHandlers();
  // Full screen: ẩn status bar + navigation bar.
  // Dùng `manual` + overlays rỗng thay vì immersiveSticky để KHÔNG reserve
  // vùng cử chỉ mép trên (vốn nuốt tap nút X ở HUD).
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
  SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

  dlog('app: loadAppVersion start');
  await loadAppVersion();
  dlog('app: loadAppVersion done');

  dlog('app: bootstrap start');
  final modules = {
    RoyCasualKitModule.storage,
    RoyCasualKitModule.locale,
    RoyCasualKitModule.lifecycle,
    RoyCasualKitModule.reminders,
    RoyCasualKitModule.wakeLock,
    RoyCasualKitModule.performance,
    if (withAudio) RoyCasualKitModule.audio,
  };
  await RoyCasualKit.initialize(
    config: RoyCasualKitConfig(
      modules: modules,
      preferences: await _loadPrefs(),
    ),
  );
  // Giữ màn hình sáng suốt theo tuỳ chọn đã lưu (mặc định bật) — xem
  // SettingsScreen's toggle để bật/tắt.
  unawaited(WakeLockService.maybe?.init());
  // ENH-80: bootstrap chỉ đăng ký service (permanent), không tự start()
  // (đúng chủ đích — xem doc comment của PerformanceTierService.start()) —
  // app thật cần tự gọi start() 1 lần nếu muốn cơ chế hysteresis FPS-gate
  // chạy thật cho AuroraBgLayer/NeonAuraLayer.
  PerformanceTierService.maybe?.start();
  final store = StorageService.to;
  dlog('app: bootstrap done');
  // IDEA-42: registered unconditionally (not gated by `withAudio`/module
  // flags) — this is a pure in-memory debug aid, no platform channel, so
  // it's safe under E2E test too and the Replay tab in DebugQaOverlay
  // needs it to not just show "chưa được đăng ký".
  Get.put(ReplayRecorder(), permanent: true);
  final DeepLinkCommandRouter deepLinkRouter =
      DeepLinkCommandRouter.maybe ??
      Get.put(
        DeepLinkCommandRouter(
          routes: const [
            DeepLinkRoute(
              commandType: 'level',
              scheme: 'roycasualkit',
              host: 'open',
              pathSegments: ['level', ':id'],
            ),
            DeepLinkRoute(
              commandType: 'shop',
              scheme: 'roycasualkit',
              host: 'open',
              pathSegments: ['shop'],
            ),
          ],
        ),
        permanent: true,
      );
  // FEAT-60: real OS deep links, gated off under E2E test the same way
  // audio is (a platform-channel stream listener isn't needed for the
  // automated boot smoke test and would just be one more thing that could
  // flake there).
  if (!isE2eTest) {
    AppLinks().uriLinkStream.listen(deepLinkRouter.handleUri);
  }
  NeonTheme.dark = store.getBool(StorageKeys.themeDark);
  NeonTheme.colorBlindSafe = store.getBool(StorageKeys.colorBlindSafe);
  final locale = LocaleService.maybe!;
  // GetMaterialApp's `locale:` param chỉ áp dụng lúc build lần đầu; nếu
  // `app()` từng chạy trước đó trong cùng process (vd test gọi lại), GetX
  // vẫn giữ `Get.locale` cũ nên phải chủ động set lại ở đây, không thể chỉ
  // dựa vào tham số constructor.
  Get.updateLocale(locale.current.value);

  runApp(RoyBaseGameApp(initialLocale: locale.current.value));
  dlog('app: runApp done');

  // Sau first frame: tránh I/O contention với Flame init → giảm startup jank.
  if (withAudio) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      AudioManager.maybe?.init().then((_) => AudioManager.maybe?.startBgm());
    });
  }
  // I56: lên lịch lại reminder mỗi lần app mở (mọi lỗi permission/plugin bị
  // nuốt bên trong scheduleNext(), không cần gate theo withAudio).
  WidgetsBinding.instance.addPostFrameCallback((_) {
    ReminderService.maybe?.scheduleNext();
  });
  // FEAT-60: mark ready right after the first frame — any deep link that
  // arrived during boot (cold-start via the launcher intent) was queued by
  // DeepLinkCommandRouter and drains here, exactly once.
  WidgetsBinding.instance.addPostFrameCallback((_) {
    DeepLinkCommandRouter.maybe?.markReady();
  });
}

/// Khởi tạo SharedPreferences; thiết bị hiếm với storage lỗi không được làm
/// app crash trắng màn hình — trả null để [StorageService] tự dùng fallback
/// in-memory.
Future<SharedPreferences?> _loadPrefs() async {
  try {
    return await SharedPreferences.getInstance();
  } catch (e) {
    dlog('SharedPreferences init failed, dùng in-memory fallback: $e');
    return null;
  }
}

bool _appVersionLoaded = false;

/// Nạp version thật từ pubspec (qua package_info_plus) vào [kAppVersion].
/// Lỗi (vd nền tảng test) → giữ nguyên fallback. Chỉ gọi platform channel 1
/// lần cho cả tiến trình — version không đổi trong lúc app đang chạy, nên
/// [_appVersionLoaded] chặn gọi lại nếu `app()` từng chạy nhiều lần trong
/// cùng process (vd test).
Future<void> loadAppVersion() async {
  if (_appVersionLoaded) return;
  try {
    final info = await PackageInfo.fromPlatform();
    if (info.version.isNotEmpty) kAppVersion = info.version;
    if (info.buildNumber.isNotEmpty) kAppBuildNumber = info.buildNumber;
    if (info.packageName.isNotEmpty) kPackageName = info.packageName;
    _appVersionLoaded = true;
  } catch (_) {
    // giữ fallback trong app_info.dart, thử lại ở lần app() kế tiếp
  }
}

class RoyBaseGameApp extends StatefulWidget {
  final Locale initialLocale;

  const RoyBaseGameApp({super.key, required this.initialLocale});

  @override
  State<RoyBaseGameApp> createState() => _RoyBaseGameAppState();
}

class _RoyBaseGameAppState extends State<RoyBaseGameApp>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  // Tạm dừng nhạc khi app ra background, phát lại khi quay vào.
  // Trước đây thiếu observer này nên nhạc vẫn chạy khi back ra launcher.
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final audio = AudioManager.maybe;
    if (state == AppLifecycleState.resumed) {
      audio?.resumeBgm();
    } else {
      // paused / inactive / hidden / detached → dừng nhạc
      audio?.pauseBgm();
      // X24: đây là mốc cuối cùng chắc chắn còn chạy trước khi OS có thể giết
      // process — đẩy nốt counter đang đệm (xem [StorageService.flush]).
      // Không await: `didChangeAppLifecycleState` là sync, và SharedPreferences
      // ghi vào bộ nhớ trước rồi mới xuống đĩa nên giá trị không mất.
      StorageService.maybe?.flush();
    }
  }

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'Roy Casual Kit Example',
      debugShowCheckedModeBanner: false,
      defaultTransition: Transition.cupertino,
      transitionDuration: const Duration(milliseconds: 280),
      translations: AppTranslations(),
      locale: widget.initialLocale,
      fallbackLocale: AppTranslations.fallback,
      supportedLocales: AppTranslations.supported,
      // Material/Cupertino localizations cho mọi ngôn ngữ (tooltip, ngày giờ,
      // semantics…) — nếu thiếu sẽ ném lỗi với locale ngoài en.
      localizationsDelegates: const [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      theme:
          ThemeData(
            useMaterial3: true,
            fontFamily: NeonTheme.fontFamily, // Baloo2 mặc định toàn app
            scaffoldBackgroundColor: NeonTheme.bgMid,
            colorScheme: ColorScheme.light(
              primary: NeonTheme.purple,
              secondary: NeonTheme.magenta,
              surface: NeonTheme.card,
              onSurface: NeonTheme.ink,
            ),
            // ponytail: SwitchListTile chỉ set màu active per-instance, state
            // OFF rơi về Switch mặc định (đen/trắng) — lệch theme candy-neon.
            // Set track/thumb OFF ở đây 1 lần cho mọi Switch trong app. Dùng
            // `ink` (đậm) thay vì `inkSoft` alpha thấp — bản cũ gần như vô
            // hình trên nền pastel sáng (candy bg).
            switchTheme: SwitchThemeData(
              trackColor: WidgetStateProperty.resolveWith(
                (states) => states.contains(WidgetState.selected)
                    ? null
                    : NeonTheme.ink.withValues(alpha: 0.28),
              ),
              trackOutlineColor: WidgetStateProperty.resolveWith(
                (states) => states.contains(WidgetState.selected)
                    ? null
                    : NeonTheme.ink.withValues(alpha: 0.45),
              ),
              thumbColor: WidgetStateProperty.resolveWith(
                (states) =>
                    states.contains(WidgetState.selected) ? null : Colors.white,
              ),
            ),
            // ponytail: Baloo2 thiếu vài glyph Cyrillic hiếm (ví dụ "ї" trong
            // "Українська") → fallback sang font hệ thống Android khi thiếu.
          ).copyWith(
            textTheme: ThemeData(
              fontFamily: NeonTheme.fontFamily,
            ).textTheme.apply(fontFamilyFallback: const ['sans-serif']),
          ),
      home: const HomeScreen(),
      // Long-press the top-right corner anywhere in the app to open a
      // read-only debug/QA panel (kDebugMode||kProfileMode only — a
      // release build never builds this at all).
      builder: (context, child) => DebugQaOverlay(child: child!),
    );
  }
}
1
likes
150
points
163
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A casual/idle-game SDK for Flutter (GetX + Flame): storage, i18n, audio, haptics, reminders, economy/progression, live-ops tooling, and a candy-styled widget kit.

Repository (GitHub)
View/report issues

Topics

#widget #game #ui #flame #casual-game

License

MIT (license)

Dependencies

crypto, flame, flame_audio, flutter, flutter_local_notifications, get, intl, qr_flutter, share_plus, shared_preferences, timezone, wakelock_plus

More

Packages that depend on roy_casual_kit