أساسيات ودجات Flutter

الوضع الداكن والتنسيقات الديناميكية

45 دقيقة الدرس 17 من 18

لماذا الوضع الداكن مهم

الوضع الداكن ليس مجرد تفضيل جمالي -- إنه ميزة حاسمة تحسن تجربة المستخدم وتقلل إجهاد العين في البيئات منخفضة الإضاءة وتوفر البطارية على شاشات OLED. يوفر Flutter دعماً من الدرجة الأولى للوضع الداكن عبر معامل darkTheme في MaterialApp وتعداد ThemeMode. في هذا الدرس، ستتعلم كيفية تنفيذ نظام وضع داكن كامل مع الكشف التلقائي والتبديل اليدوي والتفضيلات المستمرة.

معيار الصناعة: جميع المنصات الرئيسية (iOS، Android، macOS، Windows) تدعم الوضع الداكن على مستوى النظام. يتوقع المستخدمون أن تحترم التطبيقات تفضيل نظامهم، وتعتبر العديد من متاجر التطبيقات دعم الوضع الداكن جزءاً من إرشادات الجودة.

ThemeMode: الحالات الثلاث

تعداد ThemeMode في Flutter يحدد أي تنسيق يستخدمه التطبيق. فهم هذه الأوضاع الثلاثة هو أساس تنفيذ الوضع الداكن.

خيارات ThemeMode

MaterialApp(
  // التنسيق الفاتح -- يُستخدم عند ThemeMode.light أو ThemeMode.system (فاتح)
  theme: AppTheme.lightTheme,

  // التنسيق الداكن -- يُستخدم عند ThemeMode.dark أو ThemeMode.system (داكن)
  darkTheme: AppTheme.darkTheme,

  // يتحكم في أي تنسيق نشط
  themeMode: ThemeMode.system,  // الافتراضي: يتبع إعداد النظام
  // ThemeMode.light   -- استخدم التنسيق الفاتح دائماً
  // ThemeMode.dark    -- استخدم التنسيق الداكن دائماً
  // ThemeMode.system  -- اتبع إعداد الوضع الداكن/الفاتح للنظام
)

إعداد التنسيقات الفاتحة والداكنة

مفتاح الوضع الداكن الجيد هو تعريف كلا التنسيقين بتصميم مقصود، وليس مجرد عكس الألوان. التنسيقات الداكنة تحتاج دراسة دقيقة للارتفاع والتباين وألوان السطح.

تعريفات كاملة للتنسيق الفاتح والداكن

class AppTheme {
  static const _seedColor = Color(0xFF1E88E5);

  static ThemeData get lightTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.light,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      scaffoldBackgroundColor: colorScheme.surface,
      appBarTheme: AppBarTheme(
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
        elevation: 0,
        scrolledUnderElevation: 1,
      ),
      cardTheme: CardTheme(
        elevation: 1,
        color: colorScheme.surface,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
      dividerTheme: DividerThemeData(
        color: colorScheme.outlineVariant,
      ),
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    );
  }

  static ThemeData get darkTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.dark,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      scaffoldBackgroundColor: colorScheme.surface,
      appBarTheme: AppBarTheme(
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
        elevation: 0,
        scrolledUnderElevation: 1,
      ),
      cardTheme: CardTheme(
        elevation: 1,
        color: colorScheme.surfaceContainerHigh,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
      dividerTheme: DividerThemeData(
        color: colorScheme.outlineVariant,
      ),
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    );
  }
}
نصيحة تصميم: في التنسيقات الداكنة، يُعبر عن الارتفاع عبر ألوان سطح أفتح بدلاً من الظلال. مخطط ألوان Material 3 يوفر تلقائياً ألوان حاوية السطح عند مستويات ارتفاع مختلفة: surfaceContainerLowest، surfaceContainerLow، surfaceContainer، surfaceContainerHigh، surfaceContainerHighest.

الكشف عن سطوع النظام

أحياناً تحتاج معرفة السطوع الحالي خارج نظام التنسيق -- مثلاً لتحميل أصول مختلفة أو تطبيق منطق شرطي.

قراءة سطوع النظام

class AdaptiveScreen extends StatelessWidget {
  const AdaptiveScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // الطريقة 1: فحص سطوع التنسيق الحالي
    final isDark = Theme.of(context).brightness == Brightness.dark;

    // الطريقة 2: فحص إعداد سطوع المنصة مباشرة
    final platformBrightness = MediaQuery.platformBrightnessOf(context);
    final isSystemDark = platformBrightness == Brightness.dark;

    // الطريقة 3: فحص سطوع ColorScheme
    final isColorSchemeDark =
      Theme.of(context).colorScheme.brightness == Brightness.dark;

    return Column(
      children: [
        // استخدام صور مختلفة حسب التنسيق
        Image.asset(
          isDark ? 'assets/logo_dark.png' : 'assets/logo_light.png',
        ),

        // ظل تكيفي
        Container(
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.surface,
            boxShadow: isDark
              ? []  // لا ظلال في الوضع الداكن
              : [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.1),
                    blurRadius: 8,
                    offset: const Offset(0, 2),
                  ),
                ],
          ),
          child: const Text('حاوية تكيفية'),
        ),
      ],
    );
  }
}

تبديل التنسيق أثناء التشغيل

للسماح للمستخدمين بالتبديل بين الأوضاع الفاتح والداكن والنظام أثناء التشغيل، تحتاج حل إدارة حالة. إليك تنفيذ نظيف باستخدام ValueNotifier:

متحكم التنسيق مع ValueNotifier

// lib/theme/theme_controller.dart
class ThemeController extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system;

  ThemeMode get themeMode => _themeMode;

  bool get isDarkMode => _themeMode == ThemeMode.dark;
  bool get isLightMode => _themeMode == ThemeMode.light;
  bool get isSystemMode => _themeMode == ThemeMode.system;

  void setThemeMode(ThemeMode mode) {
    if (_themeMode != mode) {
      _themeMode = mode;
      notifyListeners();
    }
  }

  void toggleTheme() {
    setThemeMode(
      _themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
    );
  }
}

// lib/main.dart
class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _themeController = ThemeController();

  @override
  void dispose() {
    _themeController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _themeController,
      builder: (context, child) {
        return MaterialApp(
          theme: AppTheme.lightTheme,
          darkTheme: AppTheme.darkTheme,
          themeMode: _themeController.themeMode,
          home: HomeScreen(themeController: _themeController),
        );
      },
    );
  }
}

واجهة تبديل التنسيق

class ThemeSettingsSection extends StatelessWidget {
  final ThemeController themeController;

  const ThemeSettingsSection({required this.themeController, super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'المظهر',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),

        // زر مقسم لاختيار التنسيق
        SegmentedButton<ThemeMode>(
          segments: const [
            ButtonSegment(
              value: ThemeMode.light,
              icon: Icon(Icons.light_mode),
              label: Text('فاتح'),
            ),
            ButtonSegment(
              value: ThemeMode.system,
              icon: Icon(Icons.settings_brightness),
              label: Text('النظام'),
            ),
            ButtonSegment(
              value: ThemeMode.dark,
              icon: Icon(Icons.dark_mode),
              label: Text('داكن'),
            ),
          ],
          selected: {themeController.themeMode},
          onSelectionChanged: (selection) {
            themeController.setThemeMode(selection.first);
          },
        ),
      ],
    );
  }
}

حفظ تفضيل التنسيق

يتوقع المستخدمون أن يتم تذكر اختيار التنسيق الخاص بهم عبر إعادة تشغيل التطبيق. استخدم SharedPreferences لحفظ الاختيار.

متحكم تنسيق مستمر

import 'package:shared_preferences/shared_preferences.dart';

class PersistentThemeController extends ChangeNotifier {
  static const _key = 'theme_mode';
  final SharedPreferences _prefs;
  ThemeMode _themeMode;

  PersistentThemeController(this._prefs)
    : _themeMode = _themeModeFromString(
        _prefs.getString(_key) ?? 'system',
      );

  ThemeMode get themeMode => _themeMode;

  Future<void> setThemeMode(ThemeMode mode) async {
    if (_themeMode != mode) {
      _themeMode = mode;
      await _prefs.setString(_key, _themeModeToString(mode));
      notifyListeners();
    }
  }

  static ThemeMode _themeModeFromString(String value) {
    return switch (value) {
      'light' => ThemeMode.light,
      'dark' => ThemeMode.dark,
      _ => ThemeMode.system,
    };
  }

  static String _themeModeToString(ThemeMode mode) {
    return switch (mode) {
      ThemeMode.light => 'light',
      ThemeMode.dark => 'dark',
      ThemeMode.system => 'system',
    };
  }
}

// التهيئة في main()
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  final themeController = PersistentThemeController(prefs);

  runApp(MyApp(themeController: themeController));
}

ألوان تكيفية لكلا الوضعين

أحياناً تحتاج ألواناً ليست جزءاً من مخطط ألوان Material لكنها تحتاج للتكيف مع الوضع الفاتح/الداكن. استخدم امتدادات التنسيق لهذا:

ألوان مخصصة تكيفية

class AdaptiveColors extends ThemeExtension<AdaptiveColors> {
  final Color? codeBackground;
  final Color? codeText;
  final Color? linkColor;
  final Color? highlightColor;
  final Color? shadowColor;
  final Color? shimmerBase;
  final Color? shimmerHighlight;

  const AdaptiveColors({
    this.codeBackground,
    this.codeText,
    this.linkColor,
    this.highlightColor,
    this.shadowColor,
    this.shimmerBase,
    this.shimmerHighlight,
  });

  // ألوان الوضع الفاتح
  static const light = AdaptiveColors(
    codeBackground: Color(0xFFF5F5F5),
    codeText: Color(0xFF37474F),
    linkColor: Color(0xFF1565C0),
    highlightColor: Color(0xFFFFF9C4),
    shadowColor: Color(0x1A000000),
    shimmerBase: Color(0xFFE0E0E0),
    shimmerHighlight: Color(0xFFF5F5F5),
  );

  // ألوان الوضع الداكن
  static const dark = AdaptiveColors(
    codeBackground: Color(0xFF1E1E2E),
    codeText: Color(0xFFCDD6F4),
    linkColor: Color(0xFF82B1FF),
    highlightColor: Color(0xFF3E2723),
    shadowColor: Color(0x40000000),
    shimmerBase: Color(0xFF303030),
    shimmerHighlight: Color(0xFF424242),
  );

  @override
  AdaptiveColors copyWith({
    Color? codeBackground,
    Color? codeText,
    Color? linkColor,
    Color? highlightColor,
    Color? shadowColor,
    Color? shimmerBase,
    Color? shimmerHighlight,
  }) {
    return AdaptiveColors(
      codeBackground: codeBackground ?? this.codeBackground,
      codeText: codeText ?? this.codeText,
      linkColor: linkColor ?? this.linkColor,
      highlightColor: highlightColor ?? this.highlightColor,
      shadowColor: shadowColor ?? this.shadowColor,
      shimmerBase: shimmerBase ?? this.shimmerBase,
      shimmerHighlight: shimmerHighlight ?? this.shimmerHighlight,
    );
  }

  @override
  AdaptiveColors lerp(AdaptiveColors? other, double t) {
    if (other is! AdaptiveColors) return this;
    return AdaptiveColors(
      codeBackground: Color.lerp(codeBackground, other.codeBackground, t),
      codeText: Color.lerp(codeText, other.codeText, t),
      linkColor: Color.lerp(linkColor, other.linkColor, t),
      highlightColor: Color.lerp(highlightColor, other.highlightColor, t),
      shadowColor: Color.lerp(shadowColor, other.shadowColor, t),
      shimmerBase: Color.lerp(shimmerBase, other.shimmerBase, t),
      shimmerHighlight: Color.lerp(shimmerHighlight, other.shimmerHighlight, t),
    );
  }
}

// التسجيل في التنسيقات
static ThemeData get lightTheme => ThemeData(
  extensions: const [AdaptiveColors.light],
  // ...
);

static ThemeData get darkTheme => ThemeData(
  extensions: const [AdaptiveColors.dark],
  // ...
);

// الاستخدام
Widget build(BuildContext context) {
  final adaptive = Theme.of(context).extension<AdaptiveColors>()!;

  return Container(
    color: adaptive.codeBackground,
    child: Text(
      'final x = 42;',
      style: TextStyle(
        color: adaptive.codeText,
        fontFamily: 'monospace',
      ),
    ),
  );
}

انتقالات التنسيق المتحركة

افتراضياً، يقوم Flutter بعمل رسوم متحركة لتغييرات التنسيق. يمكنك تخصيص مدة الرسوم المتحركة وحتى استخدام AnimatedTheme لمزيد من التحكم:

رسوم متحركة مخصصة للتنسيق

// MaterialApp يقوم تلقائياً بعمل رسوم متحركة بين التنسيقات
// تخصيص المدة:
MaterialApp(
  theme: AppTheme.lightTheme,
  darkTheme: AppTheme.darkTheme,
  themeMode: currentMode,
  themeAnimationDuration: const Duration(milliseconds: 400),
  themeAnimationCurve: Curves.easeInOut,
)

// لرسوم متحركة يدوية داخل شجرة فرعية:
class AnimatedThemeExample extends StatelessWidget {
  final bool isDark;
  const AnimatedThemeExample({required this.isDark, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedTheme(
      data: isDark ? AppTheme.darkTheme : AppTheme.lightTheme,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOutCubic,
      child: Builder(
        builder: (context) {
          final theme = Theme.of(context);
          return Container(
            color: theme.colorScheme.surface,
            child: Text(
              'هذا ينتقل بسلاسة!',
              style: TextStyle(color: theme.colorScheme.onSurface),
            ),
          );
        },
      ),
    );
  }
}

مثال عملي: تطبيق كامل واعي بالنظام

لنبنِ مثالاً كاملاً يربط كل شيء معاً -- كشف النظام، التبديل اليدوي، التفضيل المستمر، والألوان التكيفية:

تنفيذ كامل للوضع الداكن

// lib/theme/app_theme.dart
import 'package:flutter/material.dart';

class AppTheme {
  static const _seed = Color(0xFF6750A4);

  static ThemeData get light => _build(
    ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.light),
    AdaptiveColors.light,
  );

  static ThemeData get dark => _build(
    ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.dark),
    AdaptiveColors.dark,
  );

  static ThemeData _build(ColorScheme scheme, AdaptiveColors adaptive) {
    return ThemeData(
      useMaterial3: true,
      colorScheme: scheme,
      scaffoldBackgroundColor: scheme.surface,
      appBarTheme: AppBarTheme(
        backgroundColor: scheme.surface,
        foregroundColor: scheme.onSurface,
        elevation: 0,
      ),
      cardTheme: CardTheme(
        elevation: scheme.brightness == Brightness.dark ? 0 : 1,
        color: scheme.brightness == Brightness.dark
          ? scheme.surfaceContainerHigh
          : scheme.surface,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
          side: scheme.brightness == Brightness.dark
            ? BorderSide(color: scheme.outlineVariant.withOpacity(0.3))
            : BorderSide.none,
        ),
      ),
      extensions: [adaptive],
    );
  }
}

// lib/main.dart
import 'package:shared_preferences/shared_preferences.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  final themeCtrl = PersistentThemeController(prefs);
  runApp(MyApp(themeController: themeCtrl));
}

class MyApp extends StatelessWidget {
  final PersistentThemeController themeController;
  const MyApp({required this.themeController, super.key});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: themeController,
      builder: (context, _) {
        return MaterialApp(
          theme: AppTheme.light,
          darkTheme: AppTheme.dark,
          themeMode: themeController.themeMode,
          themeAnimationDuration: const Duration(milliseconds: 300),
          home: SettingsScreen(themeController: themeController),
        );
      },
    );
  }
}

// lib/screens/settings_screen.dart
class SettingsScreen extends StatelessWidget {
  final PersistentThemeController themeController;
  const SettingsScreen({required this.themeController, super.key});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final adaptive = theme.extension<AdaptiveColors>()!;

    return Scaffold(
      appBar: AppBar(title: const Text('الإعدادات')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // محدد وضع التنسيق
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('التنسيق', style: theme.textTheme.titleMedium),
                  const SizedBox(height: 12),
                  SegmentedButton<ThemeMode>(
                    segments: const [
                      ButtonSegment(
                        value: ThemeMode.light,
                        icon: Icon(Icons.light_mode),
                        label: Text('فاتح'),
                      ),
                      ButtonSegment(
                        value: ThemeMode.system,
                        icon: Icon(Icons.auto_mode),
                        label: Text('تلقائي'),
                      ),
                      ButtonSegment(
                        value: ThemeMode.dark,
                        icon: Icon(Icons.dark_mode),
                        label: Text('داكن'),
                      ),
                    ],
                    selected: {themeController.themeMode},
                    onSelectionChanged: (s) =>
                      themeController.setThemeMode(s.first),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),

          // بطاقة معاينة تعرض الألوان التكيفية
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('معاينة', style: theme.textTheme.titleMedium),
                  const SizedBox(height: 12),
                  Container(
                    padding: const EdgeInsets.all(12),
                    decoration: BoxDecoration(
                      color: adaptive.codeBackground,
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      'void main() => runApp(MyApp());',
                      style: TextStyle(
                        color: adaptive.codeText,
                        fontFamily: 'monospace',
                        fontSize: 14,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}
خطأ شائع: لا تستخدم أبداً ألواناً مشفرة مثل Colors.white أو Colors.black للخلفيات والنصوص. استخدم دائماً colorScheme.surface، colorScheme.onSurface، إلخ. الألوان المشفرة ستبدو خاطئة في وضع التنسيق المعاكس -- نص أبيض على خلفية بيضاء في الوضع الفاتح، أو أيقونات داكنة غير مرئية على خلفيات داكنة.

لوحات ألوان مخصصة لكل وضع

للتطبيقات التي تحتاج لوحات ألوان مختلفة بشكل كبير في كل وضع (وليس مجرد متغيرات فاتحة/داكنة لنفس البذرة)، يمكنك تعريف مخططات ألوان منفصلة تماماً:

لوحات مختلفة لكل وضع

class AppTheme {
  // الوضع الفاتح: لوحة رمادية زرقاء دافئة
  static final _lightScheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF546E7A),
    brightness: Brightness.light,
  ).copyWith(
    primary: const Color(0xFF37474F),
    secondary: const Color(0xFFFF8A65),
    tertiary: const Color(0xFF66BB6A),
  );

  // الوضع الداكن: لوحة بنفسجية زرقاء عميقة (مزاج مختلف)
  static final _darkScheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF7C4DFF),
    brightness: Brightness.dark,
  ).copyWith(
    primary: const Color(0xFFB388FF),
    secondary: const Color(0xFFFF8A80),
    tertiary: const Color(0xFF69F0AE),
  );

  static ThemeData get lightTheme => ThemeData(
    useMaterial3: true,
    colorScheme: _lightScheme,
    extensions: const [AdaptiveColors.light],
  );

  static ThemeData get darkTheme => ThemeData(
    useMaterial3: true,
    colorScheme: _darkScheme,
    extensions: const [AdaptiveColors.dark],
  );
}

الملخص

النقاط الرئيسية:
  • ThemeMode.system يتبع إعداد النظام، .light يفرض الفاتح، .dark يفرض الداكن.
  • عرّف دائماً كلا من theme وdarkTheme في MaterialApp للسلوك الصحيح الواعي بالنظام.
  • استخدم MediaQuery.platformBrightnessOf(context) للكشف عن سطوع النظام مباشرة.
  • نفّذ ThemeController مع ChangeNotifier لتبديل التنسيق أثناء التشغيل.
  • احفظ تفضيل التنسيق مع SharedPreferences حتى لا يضطر المستخدمون لتعيينه كل مرة.
  • استخدم ThemeExtension للألوان المخصصة التكيفية التي تختلف بين الوضعين الفاتح والداكن.
  • في الوضع الداكن، عبّر عن الارتفاع عبر درجات سطح أفتح وليس الظلال.
  • لا تشفر أبداً Colors.white/black -- استخدم دائماً ألوان colorScheme الدلالية.
  • Flutter يقوم تلقائياً بعمل رسوم متحركة لانتقالات التنسيق؛ خصصها مع themeAnimationDuration.