Flutter: نظام السمات والتنسيق

السمة هي خزانة ملابس التطبيق — أسلوب موحد يُمكّن المستخدمين من التعرف على علامتك التجارية بنظرة واحدة.

📋 المتطلبات السابقة: يجب أن تكون ملمًا بـ

1. ما ستتعلمه


2. قصة حقيقية عن فوضى العلامة التجارية

(1) المشكلة: مئة صفحة، مئة أزرق

يُطوّر ShopApp لبوب 3 مطورين، كل يُعرّف ألوانه الخاصة: Colors.blue، Color(0xFF2196F3)، Color(0xFF1976D2). عبر 100 صفحة يوجد 12 لون أزرق مختلف، وأنصاف أقطار أزرار الأركان تأتي بـ 4px و8px و12px. الوضع الداكن أكثر فوضى — البعض يستخدم نص Colors.white على خلفيات داكنة، وآخرون يستخدمون Colors.grey[300]. التعرف على العلامة التجارية شبه معدوم.

(2) حل ThemeData

يُعرّف ThemeData بشكل موحد جميع الألوان والخطوط والأشكال — المكوّنات تستمد الأنماط تلقائيًا من السمة، وتغيير واحد يسري عالميًا.

DART
import 'package:flutter/material.dart';

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF0066CC)),
    appBarTheme: const AppBarTheme(centerTitle: true),
    cardTheme: CardThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
  ),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: وحدة العلامة التجارية + تبديل عام

بعد تعريف بوب لسمة علامة ShopApp التجارية، شاركت جميع الصفحات تلقائيًا أسلوبًا موحدًا. الوضع الداكن احتاج فقط تبديل ThemeData — بدون تغييرات لكل صفحة.


3. نظام إعدادات ThemeData

100%
graph TD
    MT[MaterialApp.theme] --> TD[ThemeData]
    TD --> CS[ColorScheme]
    TD --> TM[TextTheme]
    TD --> AT[AppBarTheme]
    TD --> CT[CardTheme]
    TD --> TE[ThemeExtension]
    TE --> Brand[BrandTokens]
    CS --> |light| Light[Light Scheme]
    CS --> |dark| Dark[Dark Scheme]
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(1) نظام ألوان ColorScheme

دور اللون الاستخدام مثال
primary اللون الرئيسي للعلامة (أزرار/تبويبات) #0066CC
onPrimary النص على اللون الرئيسي #FFFFFF
secondary لون تمييزي #FF6B35
surface بطاقة/خلفية #FFFFFF/#1C1C1E
onSurface النص على السطح #1C1C1E/#FFFFFF
error لون الخطأ #B3261E
outline حدود/فواصل #79747E

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تعريفات ألوان علامة ShopApp التجارية

DART
import 'package:flutter/material.dart';

class ShopAppColors {
  static const primary = Color(0xFF0066CC);
  static const secondary = Color(0xFFFF6B35);
  static const surface = Color(0xFFFFFFFF);
  static const onSurface = Color(0xFF1C1C1E);

  static final light = ColorScheme.light(
    primary: primary,
    secondary: secondary,
    surface: surface,
    onSurface: onSurface,
    error: const Color(0xFFB3261E),
  );

  static final dark = ColorScheme.dark(
    primary: const Color(0xFF80B3FF),
    secondary: const Color(0xFFFF9B75),
    surface: const Color(0xFF1C1C1E),
    onSurface: const Color(0xFFE6E6E6),
    error: const Color(0xFFF2B8B5),
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. إعدادات ThemeData الكاملة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: سمة ShopApp الكاملة

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - ShopAppColors: see Section 3 brand color definitions in this lesson

ThemeData shopAppLightTheme(ColorScheme colorScheme) => ThemeData(
  useMaterial3: true,
  colorScheme: colorScheme,
  // AppBar
  appBarTheme: AppBarTheme(
    centerTitle: true,
    elevation: 0,
    backgroundColor: colorScheme.surface,
    foregroundColor: colorScheme.onSurface,
  ),
  // Card
  cardTheme: CardThemeData(
    elevation: 2,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
    clipBehavior: Clip.antiAlias,
  ),
  // Elevated/Filled Button
  filledButtonTheme: FilledButtonThemeData(
    style: FilledButton.styleFrom(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    ),
  ),
  // Input
  inputDecorationTheme: InputDecorationTheme(
    filled: true,
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
    contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  ),
  // Bottom Nav
  navigationBarTheme: NavigationBarThemeData(
    indicatorColor: colorScheme.primary.withOpacity(0.1),
  ),
  // Text
  textTheme: ThemeData.light().textTheme.apply(
    fontFamily: 'Roboto',
  ),
);

ThemeData shopAppDarkTheme(ColorScheme colorScheme) => shopAppLightTheme(colorScheme).copyWith(
  brightness: Brightness.dark,
  scaffoldBackgroundColor: const Color(0xFF0D0D0F),
);
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. التبديل الديناميكي للسمات

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: إدارة السمات بـ Riverpod

DART
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';

// ⚙️ تثبيت التبعيات: flutter pub add flutter_riverpod riverpod_annotation shared_preferences
// ⚙️ تبعيات تطوير: flutter pub add --dev riverpod_generator build_runner

// Custom class definition source:
// - shopAppLightTheme/shopAppDarkTheme: see Section 4 in this lesson
// - ShopAppColors: see Section 3 brand color definitions in this lesson

enum AppThemeMode { light, dark, system }

@riverpod
class ThemeModeNotifier extends _$ThemeModeNotifier {
  @override
  AppThemeMode build() => AppThemeMode.system;

  void setMode(AppThemeMode mode) {
    state = mode;
    SharedPreferences.getInstance().then((prefs) => prefs.setString('theme_mode', mode.name));
  }

  ThemeMode get flutterThemeMode => switch (state) {
    AppThemeMode.light => ThemeMode.light,
    AppThemeMode.dark => ThemeMode.dark,
    AppThemeMode.system => ThemeMode.system,
  };
}

// In MaterialApp
class ShopApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeMode = ref.watch(themeModeNotifierProvider).flutterThemeMode;
    return MaterialApp(
      theme: shopAppLightTheme(ShopAppColors.light),
      darkTheme: shopAppDarkTheme(ShopAppColors.dark),
      themeMode: themeMode,
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: صفحة إعدادات تبديل السمات

DART
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// ⚙️ تثبيت التبعيات: flutter pub add flutter_riverpod

// Custom class definition source:
// - AppThemeMode/themeModeNotifierProvider: see Section 5 Riverpod theme management in this lesson

class ThemeSettingsPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final current = ref.watch(themeModeNotifierProvider);
    return Scaffold(
      appBar: AppBar(title: const Text('Appearance')),
      body: ListView(children: AppThemeMode.values.map((mode) {
        final label = switch (mode) {
          AppThemeMode.light => 'Light',
          AppThemeMode.dark => 'Dark',
          AppThemeMode.system => 'System',
        };
        return RadioListTile<AppThemeMode>(
          title: Text(label),
          value: mode,
          groupValue: current,
          onChanged: (v) => ref.read(themeModeNotifierProvider.notifier).setMode(v!),
        );
      }).toList()),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. امتدادات ThemeExtension المخصصة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: امتداد رموز العلامة التجارية

DART
import 'package:flutter/material.dart';
import 'dart:ui';

// Custom class definition source: BrandTokens is a custom ThemeExtension in this lesson

class BrandTokens extends ThemeExtension<BrandTokens> {
  final Color salePrice;
  final Color discount;
  final Color rating;
  final double cardRadius;
  final double productImageRatio;

  const BrandTokens({
    this.salePrice = Colors.green,
    this.discount = Colors.red,
    this.rating = Colors.amber,
    this.cardRadius = 12,
    this.productImageRatio = 0.75,
  });

  @override
  BrandTokens copyWith({Color? salePrice, Color? discount, Color? rating,
    double? cardRadius, double? productImageRatio}) {
    return BrandTokens(
      salePrice: salePrice ?? this.salePrice,
      discount: discount ?? this.discount,
      rating: rating ?? this.rating,
      cardRadius: cardRadius ?? this.cardRadius,
      productImageRatio: productImageRatio ?? this.productImageRatio,
    );
  }

  @override
  BrandTokens lerp(covariant BrandTokens? other, double t) {
    if (other == null) return this;
    return BrandTokens(
      salePrice: Color.lerp(salePrice, other.salePrice, t)!,
      discount: Color.lerp(discount, other.discount, t)!,
      rating: Color.lerp(rating, other.rating, t)!,
      cardRadius: lerpDouble(cardRadius, other.cardRadius, t)!,
      productImageRatio: lerpDouble(productImageRatio, other.productImageRatio, t)!,
    );
  }
}

// Use in ThemeData
ThemeData(extensions: const [BrandTokens()])

// Access in widget
final brand = Theme.of(context).extension<BrandTokens>()!;
Text('\$99.99', style: TextStyle(color: brand.salePrice));
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. وضع النص الكبير لإمكانية الوصول

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تحجيم نص متجاوب

DART
import 'package:flutter/material.dart';

class AccessibleText extends StatelessWidget {
  final String text;
  final TextStyle? style;
  const AccessibleText(this.text, {super.key, this.style});

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    // Clamp text scale factor for accessibility
    final scaleFactor = mediaQuery.textScaleFactor.clamp(0.8, 2.0);
    return MediaQuery(
      data: mediaQuery.copyWith(textScaleFactor: scaleFactor),
      child: Text(text, style: style),
    );
  }
}

// Global text scale limit in MaterialApp
builder: (context, child) {
  final mq = MediaQuery.of(context);
  return MediaQuery(
    data: mq.copyWith(textScaleFactor: mq.textScaleFactor.clamp(0.8, 1.5)),
    child: child!,
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

8. مثال كامل: نظام سمات ShopApp

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - ShopAppColors: see Section 3 brand color definitions in this lesson
// - BrandTokens: see Section 6 ThemeExtension in this lesson

class ShopAppTheme {
  static ThemeData light() => ThemeData(
    useMaterial3: true,
    colorScheme: ShopAppColors.light,
    extensions: const [BrandTokens()],
    appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
    cardTheme: CardThemeData(elevation: 2,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
    filledButtonTheme: FilledButtonThemeData(
      style: FilledButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
    inputDecorationTheme: InputDecorationTheme(filled: true,
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
  );

  static ThemeData dark() => ThemeData(
    useMaterial3: true,
    colorScheme: ShopAppColors.dark,
    extensions: const [BrandTokens(
      salePrice: Color(0xFF4CAF50),
      discount: Color(0xFFEF5350),
    )],
    appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
    scaffoldBackgroundColor: const Color(0xFF0D0D0F),
    cardTheme: CardThemeData(elevation: 1, color: const Color(0xFF1C1C1E),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
  );
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س ما الفرق بين ColorScheme.fromSeed والتعريف اليدوي؟
ج fromSeed يُولّد تلقائيًا لوحة متناسقة كاملة (12 لونًا)؛ التعريف اليدوي أكثر مرونة لكن يجب ضمان التناسق بنفسك. يُنصح بـ fromSeed كنقطة بداية، ثم الضبط الدقيق.
س هل Theme.of(context) له تكلفة أداء؟
ج نعم، كل استدعاء يُشغّل إعادة بناء. خزّنه خارج build: final theme = Theme.of(context);، ثم أعد استخدام المتغير.
س كيف أتجاوز السمة العامة لمكوّن؟
ج غلّف الشجرة الفرعية التي تحتاج تجاوزًا بـ Theme(data: Theme.of(context).copyWith(...), child: widget).
س كيف أتعامل مع ألوان الصور/الأيقونات في الوضع الداكن؟
ج استخدم ألوان أدوار ColorScheme (مثل onSurface)، وتجنب الألوان المشفرة ثابتة. استخدم colorFilter لتلوين أيقونات SVG.
س ماذا تفعل طريقة lerp في ThemeExtension؟
ج lerp تُنفذ حركة الاستيفاء أثناء تبديل السمات — انتقال فاتح←داكن سلس بدلًا من القفز.
س كيف أجعل مكوّنات الحزم الخارجية تتبع سمات مخصصة؟
ج معظم حزم Material تتبع ThemeData تلقائيًا. المكوّنات غير Material تحتاج تمرير معاملات السمة يدويًا.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): استخدم ColorScheme.fromSeed لإنشاء سمة ألوان العلامة التجارية وتطبيقها على MaterialApp.
  2. متوسط (الصعوبة ⭐⭐): نفّذ تبديل ثلاثي الأوضاع فاتح/داكن/نظام باستخدام Riverpod + استمرارية SharedPreferences.
  3. متقدم (الصعوبة ⭐⭐⭐): أنشئ نظام سمات ShopApp كامل: امتداد BrandTokens + سمات مزدوجة فاتح/داكن + وضع إمكانية الوصول بالنص الكبير + تجاوزات سمة على مستوى المكوّن.

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%