استخدِم تطبيق Flutter كالملل لإنشاء مظهر جميل.

تحسين تطبيقك المكتوب بلغة Flutter من الممل إلى الجميل

لمحة عن هذا الدرس التطبيقي حول الترميز

subjectتاريخ التعديل الأخير: يونيو 24, 2025
account_circleتأليف: The Flutter Team

1. مقدمة

‫Flutter هي مجموعة أدوات واجهة المستخدم من Google لإنشاء تطبيقات محلية ومميّزة للأجهزة الجوّالة والويب وأجهزة الكمبيوتر المكتبي من خلال قاعدة رموز برمجية واحدة. يعمل Flutter مع الرموز البرمجية الحالية، ويستخدمه المطوّرون والمؤسسات في جميع أنحاء العالم، وهو مجاني ومفتوح المصدر.

في هذا الدرس التطبيقي حول الترميز، يمكنك تحسين تطبيق موسيقى في Flutter، ما يجعله أكثر روعة. ولتحقيق ذلك، يستخدم هذا الدرس التطبيقي الأدوات وواجهات برمجة التطبيقات التي تم تقديمها في Material 3.

  • كيفية كتابة تطبيق Flutter سهل الاستخدام وجميل على جميع المنصات
  • كيفية تصميم النصوص في تطبيقك للتأكّد من أنّها تساهم في تحسين تجربة المستخدم
  • كيفية اختيار الألوان المناسبة وتخصيص التطبيقات المصغّرة وإنشاء مظهرك الخاص وتطبيق الوضع الداكن بسرعة
  • كيفية إنشاء تطبيقات قابلة للتكيّف على جميع المنصات
  • كيفية إنشاء تطبيقات تبدو رائعة على أي شاشة
  • كيفية إضافة حركة إلى تطبيق Flutter لإضفاء لمسة مميزة عليه

المتطلبات الأساسية

يفترض هذا الدرس التطبيقي حول الترميز أنّ لديك بعض الخبرة في استخدام Flutter. إذا لم تكن كذلك، ننصحك بمعرفة الأساسيات أولاً. يمكنك الاطّلاع على الروابط التالية للحصول على مزيد من المعلومات:

ما ستُنشئه

يرشدك هذا الدرس التطبيقي إلى كيفية إنشاء الشاشة الرئيسية لتطبيق يُسمى MyArtist، وهو تطبيق مشغّل موسيقى يتيح للمعجبين الاطّلاع على آخر أخبار الفنّانين المفضّلين لديهم. ويناقش هذا الدليل كيفية تعديل تصميم تطبيقك ليبدو جميلًا على جميع المنصات.

توضّح الفيديوهات التالية طريقة عمل التطبيق عند الانتهاء من هذا الدليل التعليمي:

ما الذي تريد تعلّمه من هذا الدليل التعليمي حول البرمجة؟

2. إعداد بيئة تطوير Flutter

تحتاج إلى برنامجَين لإكمال هذا الدرس التطبيقي، وهما حزمة تطوير البرامج (SDK) من Flutter ومحرِّر.

يمكنك تشغيل ورشة التعلم البرمجي باستخدام أيّ من الأجهزة التالية:

  • جهاز Android أو iOS متصل بالكمبيوتر ومفعَّل فيه وضع المطوّر
  • محاكي iOS (يتطلب تثبيت أدوات Xcode)
  • محاكي Android (يتطلب الإعداد في "استوديو Android")
  • متصفّح (يجب استخدام Chrome لتصحيح الأخطاء)
  • كتطبيق سطح مكتب Windows أو Linux أو macOS يجب إجراء عملية التطوير على المنصة التي تريد نشر التطبيق عليها. لذلك، إذا أردت تطوير تطبيق مخصّص لأجهزة الكمبيوتر المكتبي التي تعمل بنظام التشغيل Windows، عليك إجراء عملية التطوير على نظام التشغيل Windows للوصول إلى سلسلة الإنشاء المناسبة. هناك متطلبات خاصة بنظام التشغيل يتم تناولها بالتفصيل على docs.flutter.dev/desktop.

3. الحصول على تطبيق Codelab Starter

استنساخه من GitHub

لنسخ هذا الدليل التعليمي من GitHub، نفِّذ الأوامر التالية:

git clone https://github.com/flutter/codelabs.git
cd codelabs/boring_to_beautiful/step_01/

للتأكّد من أنّ كل شيء يعمل على ما يرام، يمكنك تشغيل تطبيق Flutter كتطبيق كمبيوتر مكتبي كما هو موضّح أدناه. بدلاً من ذلك، يمكنك فتح هذا المشروع في بيئة تطوير البرامج المتكاملة (IDE) واستخدام أدواتها لتشغيل التطبيق.

flutter run

اكتمال النقل بنجاح يجب أن يكون رمز التشغيل لشاشة MyArtist الرئيسية قيد التشغيل. من المفترض أن تظهر لك شاشة MyArtist الرئيسية. يبدو جيدًا على الكمبيوتر المكتبي، ولكنّه على الأجهزة الجوّالة... ليس رائعًا. على سبيل المثال، لا يراعي هذا الإطار الشاشة المُثقوبة. لا داعي للقلق، يمكنك حلّ هذه المشكلة.

1e67c60667821082.pngd1139cde225de452.png

جولة في الرمز

بعد ذلك، يمكنك جولة في الرمز البرمجي.

افتح lib/src/features/home/view/home_screen.dart الذي يحتوي على ما يلي:

lib/src/features/home/view/home_screen.dart

import 'package:flutter/material.dart';

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../../utils/adaptive_components.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
 
const HomeScreen({super.key});

 
@override
 
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
 
@override
 
Widget build(BuildContext context) {
   
final PlaylistsProvider playlistProvider = PlaylistsProvider();
   
final List<Playlist> playlists = playlistProvider.playlists;
   
final Playlist topSongs = playlistProvider.topSongs;
   
final Playlist newReleases = playlistProvider.newReleases;
   
final ArtistsProvider artistsProvider = ArtistsProvider();
   
final List<Artist> artists = artistsProvider.artists;
   
return LayoutBuilder(
     
builder: (context, constraints) {
       
return Scaffold(
         
body: SingleChildScrollView(
           
child: AdaptiveColumn(
             
children: [
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
mainAxisAlignment: MainAxisAlignment.spaceBetween,
                     
children: [
                       
Expanded(
                         
child: Text(
                           
'Good morning',
                           
style: context.displaySmall,
                         
),
                       
),
                       
const SizedBox(width: 20),
                       
const BrightnessToggle(),
                     
],
                   
),
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Column(
                   
children: [
                     
const HomeHighlight(),
                     
LayoutBuilder(
                       
builder: (context, constraints) => HomeArtists(
                         
artists: artists,
                         
constraints: constraints,
                       
),
                     
),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Column(
                   
crossAxisAlignment: CrossAxisAlignment.start,
                   
children: [
                     
Padding(
                       
padding: const EdgeInsets.all(2),
                       
child: Text(
                         
'Recently played',
                         
style: context.headlineSmall,
                       
),
                     
),
                     
HomeRecent(playlists: playlists),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
crossAxisAlignment: CrossAxisAlignment.start,
                     
children: [
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'Top Songs Today',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: topSongs,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'New Releases',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: newReleases,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                     
],
                   
),
                 
),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}

يستورد هذا الملف material.dart وينفّذ تطبيقًا مصغّرًا يعتمد على الحالة باستخدام فئتين:

  • توفّر عبارة import مكونات Material.
  • تمثّل فئة HomeScreen الصفحة بأكملها التي يتم عرضها.
  • تنشئ طريقة build() لفئة _HomeScreenState جذر شجرة التطبيقات المصغّرة، ما يؤثر في كيفية إنشاء جميع التطبيقات المصغّرة في واجهة المستخدم.

4. الاستفادة من أسلوب الخط

النص متوفّر في كل مكان. النص هو طريقة مفيدة للتواصل مع المستخدم. هل تطبيقك مخصّص للترفيه أو للاستخدام اليومي، أم هو تطبيق موثوق به ومناسب للعمل؟ هناك سبب لعدم استخدام تطبيقك المصرفي المفضّل لخط Comic Sans. تساهم طريقة عرض النص في تشكيل الانطباع الأول للمستخدم عن تطبيقك. في ما يلي بعض الطرق لاستخدام النص بشكل مدروس.

عرض المحتوى بدلاً من سرده

"أظهِر" بدلاً من "أخبر" كلما أمكن على سبيل المثال، يحتوي الرمز NavigationRail في التطبيق المبدئي على علامات تبويب لكل مسار رئيسي، ولكن الرموز الرئيسية متطابقة:

86c5f73b3aa5fd35.png

لا يساعد ذلك المستخدم لأنّه لا يزال عليه قراءة نص كل علامة تبويب. ابدأ بإضافة إشارات مرئية حتى يتمكّن المستخدم من إلقاء نظرة سريعة على الرموز الرئيسية للعثور على علامة التبويب التي تريدها. ويساعد ذلك أيضًا في عملية الترجمة وتحسين إمكانية الاستخدام.

في lib/src/shared/router.dart، أضِف رموزًا رئيسية مميّزة لكل وجهة تنقّل (الصفحة الرئيسية وقائمة التشغيل والمستخدمون):

lib/src/shared/router.dart

const List<NavigationDestination> destinations = [
  NavigationDestination(
    label: 'Home',
    icon: Icon(Icons.home),                                      // Modify this line
    route: '/',
  ),
  NavigationDestination(
    label: 'Playlists',
    icon: Icon(Icons.playlist_add_check),                        // Modify this line
    route: '/playlists',
  ),
  NavigationDestination(
    label: 'Artists',
    icon: Icon(Icons.people),                                    // Modify this line
    route: '/artists',
  ),
];

23278e4f4610fbf4.png

هل لديك مشاكل؟

إذا كان تطبيقك لا يعمل بشكل صحيح، ابحث عن أخطاء إملائية. إذا لزم الأمر، يمكنك استخدام الرمز المتوفّر في الروابط التالية للعودة إلى المسار الصحيح.

اختيار الخطوط بعناية

تحدّد الخطوط شخصية تطبيقك، لذا من المهم اختيار الخط المناسب. عند اختيار خط، إليك بعض النقاط التي يجب مراعاتها:

  • الخطّ المُسطّح أو الخطّ المُسنّد: تحتوي الخطوط المُسنّدة على خطوط زخرفية أو "ذيول" في نهاية الأحرف، ويُنظر إليها على أنّها أكثر رسمية. لا تحتوي الخطوط غير المزوّدة بزخارف على الخطوط الزخرفية، ويُنظر إليها عادةً على أنّها أكثر رسمية. حرف T كبير بخط بدون ذنابة وحرف T كبير بخط ذي ذنابة
  • الخطوط التي تستخدم أحرفًا كبيرة بالكامل: إنّ استخدام أحرف كبيرة بالكامل مناسب لجذب الانتباه إلى أجزاء صغيرة من النص (مثل العناوين)، ولكن عند الإفراط في استخدامه، قد يُنظر إليه على أنّه صراخ، ما يدفع المستخدم إلى تجاهله تمامًا.
  • حالة أحرف العنوان أو حالة أحرف الجملة: عند إضافة عناوين أو تصنيفات، ننصحك بالتفكير في كيفية استخدام الأحرف الكبيرة: حالة أحرف العنوان، حيث يتم كتابة الحرف الأول من كل كلمة بحرف كبير ("This Is a Title Case Title")، هي أكثر رسمية. حالة أحرف الجملة، التي تستخدم الأحرف الكبيرة في أسماء الأعلام والكلمة الأولى فقط في النص ("This is a sentence case title")، هي أكثر أسلوبًا حواريًا وغير رسمي.
  • مقياس التداخل (المسافة بين كل حرف) وطول السطر (عرض النص الكامل على الشاشة) وارتفاع السطر (ارتفاع كل سطر من النص): إنّ زيادة أيٍّ من هذه العناصر أو نقصانه يجعل تطبيقك أقل سهولة في القراءة. على سبيل المثال، قد يكون من الصعب تذكر الموضع الذي وصلت إليه عند قراءة كتلة كبيرة من النص غير المنقطع.

مع أخذ ذلك في الاعتبار، انتقِل إلى "خطوط Google" واختَر خطًا بدون خطوط مُسنّنة، مثل Montserrat، لأنّ تطبيق الموسيقى مخصّص للترفيه والمرح.

من سطر الأوامر، استخدِم الأمر pull لتحميل حزمة google_fonts. يؤدي ذلك أيضًا إلى تعديل ملف pubspec.yaml لإضافة الخطوط كعنصر تابع للتطبيق.

flutter pub add google_fonts

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Make sure the following two lines are present -->
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

في lib/src/shared/extensions.dart، استورِد الحزمة الجديدة:

lib/src/shared/extensions.dart

import 'package:google_fonts/google_fonts.dart'; // Add this line.

ضبط Montserrat TextTheme:

TextTheme get textTheme => GoogleFonts.montserratTextTheme(theme.textTheme); // Modify this line

أعِد تحميل 7f9a9e103c7b5e5.png فورًا لتفعيل التغييرات. (استخدِم الزر في بيئة تطوير البرامج المتكاملة أو أدخِل r من سطر الأوامر لإعادة التحميل السريع):

1e67c60667821082.png

من المفترض أن تظهر لك رموز NavigationRail الجديدة مع النص المعروض بخط Montserrat.

هل لديك مشاكل؟

إذا كان تطبيقك لا يعمل بشكل صحيح، ابحث عن أخطاء إملائية. إذا لزم الأمر، استخدِم الرمز المتوفّر في الروابط التالية للعودة إلى المسار الصحيح.

5. ضبط المظهر

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

عادةً ما ينشئ مطوّرو Flutter مكوّنات ذات مظهر مخصّص بإحدى الطريقتَين التاليتَين:

  • إنشاء تطبيقات مصغّرة مخصّصة فردية، ولكل منها مظهر خاص به
  • إنشاء مظاهر على مستوى التطبيق للتطبيقات المصغّرة التلقائية

يستخدم هذا المثال موفِّر مظاهر في lib/src/shared/providers/theme.dart لإنشاء تطبيقات مصغّرة وألوان متسقة في جميع أنحاء التطبيق:

lib/src/shared/providers/theme.dart

import 'dart:math';

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

class NoAnimationPageTransitionsBuilder extends PageTransitionsBuilder {
 
const NoAnimationPageTransitionsBuilder();

 
@override
 
Widget buildTransitions<T>(
   
PageRoute<T> route,
   
BuildContext context,
   
Animation<double> animation,
   
Animation<double> secondaryAnimation,
   
Widget child,
 
) {
   
return child;
 
}
}

class ThemeSettingChange extends Notification {
 
ThemeSettingChange({required this.settings});
 
final ThemeSettings settings;
}

class ThemeProvider extends InheritedWidget {
 
const ThemeProvider({
   
super.key,
   
required this.settings,
   
required this.lightDynamic,
   
required this.darkDynamic,
   
required super.child,
 
});

 
final ValueNotifier<ThemeSettings> settings;
 
final ColorScheme? lightDynamic;
 
final ColorScheme? darkDynamic;

 
final pageTransitionsTheme = const PageTransitionsTheme(
   
builders: <TargetPlatform, PageTransitionsBuilder>{
     
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
     
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
     
TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
     
TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
     
TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
   
},
 
);

 
Color custom(CustomColor custom) {
   
if (custom.blend) {
     
return blend(custom.color);
   
} else {
     
return custom.color;
   
}
 
}

 
Color blend(Color targetColor) {
   
return Color(
     
Blend.harmonize(
       
targetColor.toARGB32(),
       
settings.value.sourceColor.toARGB32(),
     
),
   
);
 
}

 
Color source(Color? target) {
   
Color source = settings.value.sourceColor;
   
if (target != null) {
     
source = blend(target);
   
}
   
return source;
 
}

 
ColorScheme colors(Brightness brightness, Color? targetColor) {
   
final dynamicPrimary = brightness == Brightness.light
       
? lightDynamic?.primary
       
: darkDynamic?.primary;
   
return ColorScheme.fromSeed(
     
seedColor: dynamicPrimary ?? source(targetColor),
     
brightness: brightness,
   
);
 
}

 
ShapeBorder get shapeMedium =>
     
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8));

 
CardThemeData cardTheme() {
   
return CardThemeData(
     
elevation: 0,
     
shape: shapeMedium,
     
clipBehavior: Clip.antiAlias,
   
);
 
}

 
ListTileThemeData listTileTheme(ColorScheme colors) {
   
return ListTileThemeData(
     
shape: shapeMedium,
     
selectedColor: colors.secondary,
   
);
 
}

 
AppBarTheme appBarTheme(ColorScheme colors) {
   
return AppBarTheme(
     
elevation: 0,
     
backgroundColor: colors.surface,
     
foregroundColor: colors.onSurface,
   
);
 
}

 
TabBarThemeData tabBarTheme(ColorScheme colors) {
   
return TabBarThemeData(
     
labelColor: colors.secondary,
     
unselectedLabelColor: colors.onSurfaceVariant,
     
indicator: BoxDecoration(
       
border: Border(bottom: BorderSide(color: colors.secondary, width: 2)),
     
),
   
);
 
}

 
BottomAppBarTheme bottomAppBarTheme(ColorScheme colors) {
   
return BottomAppBarTheme(color: colors.surface, elevation: 0);
 
}

 
BottomNavigationBarThemeData bottomNavigationBarTheme(ColorScheme colors) {
   
return BottomNavigationBarThemeData(
     
type: BottomNavigationBarType.fixed,
     
backgroundColor: colors.surfaceContainerHighest,
     
selectedItemColor: colors.onSurface,
     
unselectedItemColor: colors.onSurfaceVariant,
     
elevation: 0,
     
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
   
);
 
}

 
NavigationRailThemeData navigationRailTheme(ColorScheme colors) {
   
return const NavigationRailThemeData();
 
}

 
DrawerThemeData drawerTheme(ColorScheme colors) {
   
return DrawerThemeData(backgroundColor: colors.surface);
 
}

 
ThemeData light([Color? targetColor]) {
   
final colorScheme = colors(Brightness.light, targetColor);
   
return ThemeData.light().copyWith(
     
colorScheme: colorScheme,
     
appBarTheme: appBarTheme(colorScheme),
     
cardTheme: cardTheme(),
     
listTileTheme: listTileTheme(colorScheme),
     
bottomAppBarTheme: bottomAppBarTheme(colorScheme),
     
bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
     
navigationRailTheme: navigationRailTheme(colorScheme),
     
tabBarTheme: tabBarTheme(colorScheme),
     
drawerTheme: drawerTheme(colorScheme),
     
scaffoldBackgroundColor: colorScheme.surface,
   
);
 
}

 
ThemeData dark([Color? targetColor]) {
   
final colorScheme = colors(Brightness.dark, targetColor);
   
return ThemeData.dark().copyWith(
     
colorScheme: colorScheme,
     
appBarTheme: appBarTheme(colorScheme),
     
cardTheme: cardTheme(),
     
listTileTheme: listTileTheme(colorScheme),
     
bottomAppBarTheme: bottomAppBarTheme(colorScheme),
     
bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
     
navigationRailTheme: navigationRailTheme(colorScheme),
     
tabBarTheme: tabBarTheme(colorScheme),
     
drawerTheme: drawerTheme(colorScheme),
     
scaffoldBackgroundColor: colorScheme.surface,
   
);
 
}

 
ThemeMode themeMode() {
   
return settings.value.themeMode;
 
}

 
ThemeData theme(BuildContext context, [Color? targetColor]) {
   
final brightness = MediaQuery.of(context).platformBrightness;
   
return brightness == Brightness.light
       
? light(targetColor)
       
: dark(targetColor);
 
}

 
static ThemeProvider of(BuildContext context) {
   
return context.dependOnInheritedWidgetOfExactType<ThemeProvider>()!;
 
}

 
@override
 
bool updateShouldNotify(covariant ThemeProvider oldWidget) {
   
return oldWidget.settings != settings;
 
}
}

class ThemeSettings {
 
ThemeSettings({required this.sourceColor, required this.themeMode});

 
final Color sourceColor;
 
final ThemeMode themeMode;
}

Color randomColor() {
 
return Color(Random().nextInt(0xFFFFFFFF));
}

const linkColor = CustomColor(name: 'Link Color', color: Color(0xFF00B0FF));

class CustomColor {
 
const CustomColor({
   
required this.name,
   
required this.color,
   
this.blend = true,
 
});

 
final String name;
 
final Color color;
 
final bool blend;

 
Color value(ThemeProvider provider) {
   
return provider.custom(this);
 
}
}

لاستخدام مقدّم الخدمة، أنشئ مثيلًا وحمِّله إلى عنصر المظهر على مستوى التطبيق في MaterialApp، والذي يقع في lib/src/shared/app.dart. سيتم اكتسابها من خلال أي عناصر Theme متداخلة:

lib/src/shared/app.dart

import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'playback/bloc/bloc.dart';
import 'providers/theme.dart';
import 'router.dart';

class MyApp extends StatefulWidget {
 
const MyApp({super.key});

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

class _MyAppState extends State<MyApp> {
 
final settings = ValueNotifier(ThemeSettings(
   
sourceColor:  Colors.pink,
   
themeMode: ThemeMode.system,
 
));
 
@override
 
Widget build(BuildContext context) {
   
return BlocProvider<PlaybackBloc>(
     
create: (context) => PlaybackBloc(),
     
child: DynamicColorBuilder(
       
builder: (lightDynamic, darkDynamic) => ThemeProvider(
           
lightDynamic: lightDynamic,
           
darkDynamic: darkDynamic,
           
settings: settings,
           
child: NotificationListener<ThemeSettingChange>(
             
onNotification: (notification) {
               
settings.value = notification.settings;
               
return true;
             
},
             
child: ValueListenableBuilder<ThemeSettings>(
               
valueListenable: settings,
               
builder: (context, value, _) {
                 
final theme = ThemeProvider.of(context); // Add this line
                 
return MaterialApp.router(
                   
debugShowCheckedModeBanner: false,
                   
title: 'Flutter Demo',
                   
theme: theme.light(settings.value.sourceColor), // Add this line
                   
routeInformationParser: appRouter.routeInformationParser,
                   
routerDelegate: appRouter.routerDelegate,
                 
);
               
},
             
),
           
)),
     
),
   
);
 
}
}

بعد إعداد المظهر، اختَر ألوانًا للتطبيق.

قد يكون من الصعب اختيار مجموعة الألوان المناسبة. قد يكون لديك فكرة عن اللون الأساسي، ولكن من المرجّح أن تريد أن يتضمّن تطبيقك أكثر من لون واحد. ما هو لون النص؟ هل لديك عنوان؟ المحتوى؟ هل هناك روابط؟ ماذا عن لون الخلفية؟ أداة "أداة إنشاء المظاهر في Material" هي أداة مستندة إلى الويب (تم طرحها في Material 3) تساعدك في اختيار مجموعة من الألوان التكميلية لتطبيقك.

لاختيار لون مصدر للتطبيق، افتح أداة إنشاء المظاهر في Material Design واستكشِف ألوانًا مختلفة لواجهة المستخدم. من المهم اختيار لون يناسب المظهر الجمالي للعلامة التجارية أو يناسب ذوقك الشخصي.

بعد إنشاء مظهر، انقر بزر الماوس الأيمن على فقاعة اللون الأساسي، سيؤدي ذلك إلى فتح مربّع حوار يحتوي على القيمة الست عشرية للّون الأساسي. انسخ هذه القيمة. (يمكنك أيضًا ضبط اللون باستخدام مربّع الحوار هذا).

نقْل القيمة السداسية العشرية للّون الأساسي إلى مقدّم المظهر على سبيل المثال، يتم تحديد اللون الست عشري #00cbe6 على النحو التالي: Color(0xff00cbe6). تُنشئ ThemeProvider ThemeData يحتوي على مجموعة الألوان التكميلية التي سبق أن اطّلعت عليها في أداة Material Theme Builder:

final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Color(0xff00cbe6), // Replace this color
   themeMode: ThemeMode.system,
 ));

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

final colors = Theme.of(context).colorScheme;

لاستخدام لون معيّن، يمكنك الوصول إلى دور لون في colorScheme. انتقِل إلى lib/src/shared/views/outlined_card.dart وأضِف حدودًا إلى OutlinedCard:

lib/src/shared/views/outlined_card.dart

class _OutlinedCardState extends State<OutlinedCard> {
  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: widget.clickable
          ? SystemMouseCursors.click
          : SystemMouseCursors.basic,
      child: Container(
        // Add from here...
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
        ),
        // ... To here.
        child: widget.child,
      ),
    );
  }
}

توفّر Material 3 أدوارًا دقيقة للألوان تكمل بعضها البعض ويمكن استخدامها في جميع أنحاء واجهة المستخدم لإضافة طبقات جديدة من التعبير. تشمل أدوار الألوان الجديدة ما يلي:

  • Primary، وOnPrimary، وPrimaryContainer، وOnPrimaryContainer
  • Secondary، وOnSecondary، وSecondaryContainer، وOnSecondaryContainer
  • Tertiary، وOnTertiary، وTertiaryContainer، وOnTertiaryContainer
  • Error، وOnError، وErrorContainer، وOnErrorContainer
  • "Background" و"OnBackground"
  • Surface، وOnSurface، وSurfaceVariant، وOnSurfaceVariant
  • Shadow، وOutline، وInversePrimary

بالإضافة إلى ذلك، تتوافق رموز التصميم الجديدة مع المظهرَين الفاتح والداكن:

7b51703ed96196a4.png

يمكن استخدام أدوار الألوان هذه لتحديد معنى وإبراز أجزاء مختلفة من واجهة المستخدم. حتى إذا لم يكن المكوّن بارزًا، لا يزال بإمكانه الاستفادة من الألوان الديناميكية.

يمكن للمستخدم ضبط مستوى سطوع التطبيق في إعدادات نظام الجهاز. في lib/src/shared/app.dart، عند ضبط الجهاز على الوضع الداكن، يجب إعادة المظهر الداكن ووضع المظهر إلى MaterialApp.

lib/src/shared/app.dart

return MaterialApp.router(
  debugShowCheckedModeBanner: false,
  title: 'Flutter Demo',
  theme: theme.light(settings.value.sourceColor),
  darkTheme: theme.dark(settings.value.sourceColor), // Add this line
  themeMode: theme.themeMode(), // Add this line
  routeInformationParser: appRouter.routeInformationParser,
  routerDelegate: appRouter.routerDelegate,
);

انقر على رمز القمر في أعلى يسار الشاشة لتفعيل الوضع الداكن.

هل لديك مشاكل؟

إذا لم يكن تطبيقك يعمل بشكل صحيح، استخدِم الرمز الوارد في الرابط التالي لاستعادة الأداء الطبيعي.

6. إضافة تصميم تكيُّفي

باستخدام Flutter، يمكنك إنشاء تطبيقات تعمل على أي جهاز تقريبًا، ولكن هذا لا يعني أنّه من المتوقّع أن يتصرف كل تطبيق بالطريقة نفسها في كل الأجهزة. أصبح المستخدمون يتوقّعون سلوكيات وميزات مختلفة من المنصّات المختلفة.

تقدّم Material حِزمًا لتسهيل العمل مع التنسيقات التكيُّفية، ويمكنك العثور على حِزم Flutter هذه على GitHub.

يجب مراعاة الاختلافات التالية في الأنظمة الأساسية عند إنشاء تطبيق متوافق مع جميع الأنظمة الأساسية:

  • طريقة الإدخال: الماوس أو اللمس أو جهاز التحكّم في الألعاب
  • حجم الخط واتجاه الجهاز ومسافة المشاهدة
  • حجم الشاشة وشكل الجهاز: هاتف، جهاز لوحي، جهاز قابل للطي، كمبيوتر مكتبي، ويب

يحتوي ملف lib/src/shared/views/adaptive_navigation.dart على فئة تنقّل يمكنك من خلالها تقديم قائمة بالوجهات والمحتوى لعرض النص الأساسي. بما أنّك تستخدم هذا التنسيق على شاشات متعددة، يتوفّر تنسيق أساسي مشترَك لنقله إلى كل عنصر فرعي. إنّ أشرطة التنقّل مناسبة لأجهزة الكمبيوتر المكتبي والشاشات الكبيرة، ولكن يمكنك جعل التنسيق متوافقًا مع الأجهزة الجوّالة من خلال عرض شريط تنقّل سفلي على الأجهزة الجوّالة بدلاً من ذلك.

lib/src/shared/views/adaptive_navigation.dart

import 'package:flutter/material.dart';

class AdaptiveNavigation extends StatelessWidget {
 
const AdaptiveNavigation({
   
super.key,
   
required this.destinations,
   
required this.selectedIndex,
   
required this.onDestinationSelected,
   
required this.child,
 
});

 
final List<NavigationDestination> destinations;
 
final int selectedIndex;
 
final void Function(int index) onDestinationSelected;
 
final Widget child;

 
@override
 
Widget build(BuildContext context) {
   
return LayoutBuilder(
     
builder: (context, dimens) {
       
if (dimens.maxWidth >= 600) {                               // Add this line
         
return Scaffold(
           
body: Row(
             
children: [
               
NavigationRail(
                 
extended: dimens.maxWidth >= 800,
                 
minExtendedWidth: 180,
                 
destinations: destinations
                     
.map(
                       
(e) => NavigationRailDestination(
                         
icon: e.icon,
                         
label: Text(e.label),
                       
),
                     
)
                     
.toList(),
                 
selectedIndex: selectedIndex,
                 
onDestinationSelected: onDestinationSelected,
               
),
               
Expanded(child: child),
             
],
           
),
         
);
       
}                                                           // Add this line
       
// Mobile Layout
       
// Add from here...
       
return Scaffold(
         
body: child,
         
bottomNavigationBar: NavigationBar(
           
destinations: destinations,
           
selectedIndex: selectedIndex,
           
onDestinationSelected: onDestinationSelected,
         
),
       
);
       
// ... To here.
     
},
   
);
 
}
}

a8487a3c4d7890c9.png

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

لجعل تطبيقك متجاوبًا، عليك إدخال بعض نقاط التوقف التكيُّفية (يجب عدم الخلط بينها وبين نقاط التوقف لتحديد الأخطاء وإصلاحها). وتحدِّد نقاط التوقف هذه أحجام الشاشة التي يجب أن يغيّر فيها تطبيقك تنسيقه.

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

إنّ طرق توسيع النطاق التالية (المحدّدة في مشروع MyArtist في lib/src/shared/extensions.dart) هي نقطة بدء جيدة عند تصميم تنسيقات محسّنة لاستهدافات مختلفة.

lib/src/shared/extensions.dart

extension BreakpointUtils on BoxConstraints {
 
bool get isTablet => maxWidth > 730;
 
bool get isDesktop => maxWidth > 1200;
 
bool get isMobile => !isTablet && !isDesktop;
}

تُعتبر الشاشة التي يزيد حجمها عن 730 بكسل (في أطول اتجاه)، ولكنّها أصغر من 1200 بكسل، شاشة جهاز لوحي. ويُعتبر أيّ حجم أكبر من 1200 بكسل كمبيوتر مكتبي. إذا لم يكن الجهاز جهازًا لوحيًا أو كمبيوترًا مكتبيًا، يتم اعتباره جهازًا جوّالاً. يمكنك الاطّلاع على مزيد من المعلومات عن نقاط التوقف التكيُّفية على material.io.

يستخدم تخطيط الشاشة الرئيسية السريع الاستجابة AdaptiveContainer وAdaptiveColumn استنادًا إلى شبكة الأعمدة الـ 12.

يحتاج التصميم التكيُّفي إلى تصميمَين: تصميم واحد للهواتف الجوّالة وتصميم آخر سريع الاستجابة للشاشات الأكبر حجمًا. في هذه المرحلة، تعرِض LayoutBuilder تنسيق سطح المكتب. في lib/src/features/home/view/home_screen.dart، أنشئ تنسيق الأجهزة الجوّالة على أنّه TabBar وTabBarView يتضمّن 4 علامات تبويب.

lib/src/features/home/view/home_screen.dart

import 'package:flutter/material.dart';

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../../utils/adaptive_components.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
 
const HomeScreen({super.key});

 
@override
 
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
 
@override
 
Widget build(BuildContext context) {
   
final PlaylistsProvider playlistProvider = PlaylistsProvider();
   
final List<Playlist> playlists = playlistProvider.playlists;
   
final Playlist topSongs = playlistProvider.topSongs;
   
final Playlist newReleases = playlistProvider.newReleases;
   
final ArtistsProvider artistsProvider = ArtistsProvider();
   
final List<Artist> artists = artistsProvider.artists;
   
return LayoutBuilder(
     
builder: (context, constraints) {
       
// Add from here...
       
if (constraints.isMobile) {
         
return DefaultTabController(
           
length: 4,
           
child: Scaffold(
             
appBar: AppBar(
               
centerTitle: false,
               
title: const Text('Good morning'),
               
actions: const [BrightnessToggle()],
               
bottom: const TabBar(
                 
isScrollable: true,
                 
tabs: [
                   
Tab(text: 'Home'),
                   
Tab(text: 'Recently Played'),
                   
Tab(text: 'New Releases'),
                   
Tab(text: 'Top Songs'),
                 
],
               
),
             
),
             
body: LayoutBuilder(
               
builder: (context, constraints) => TabBarView(
                 
children: [
                   
SingleChildScrollView(
                     
child: Column(
                       
children: [
                         
const HomeHighlight(),
                         
HomeArtists(
                           
artists: artists,
                           
constraints: constraints,
                         
),
                       
],
                     
),
                   
),
                   
HomeRecent(playlists: playlists, axis: Axis.vertical),
                   
PlaylistSongs(playlist: topSongs, constraints: constraints),
                   
PlaylistSongs(
                     
playlist: newReleases,
                     
constraints: constraints,
                   
),
                 
],
               
),
             
),
           
),
         
);
       
}
       
// ... To here.

       
return Scaffold(
         
body: SingleChildScrollView(
           
child: AdaptiveColumn(
             
children: [
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
mainAxisAlignment: MainAxisAlignment.spaceBetween,
                     
children: [
                       
Expanded(
                         
child: Text(
                           
'Good morning',
                           
style: context.displaySmall,
                         
),
                       
),
                       
const SizedBox(width: 20),
                       
const BrightnessToggle(),
                     
],
                   
),
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Column(
                   
children: [
                     
const HomeHighlight(),
                     
LayoutBuilder(
                       
builder: (context, constraints) => HomeArtists(
                         
artists: artists,
                         
constraints: constraints,
                       
),
                     
),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Column(
                   
crossAxisAlignment: CrossAxisAlignment.start,
                   
children: [
                     
Padding(
                       
padding: const EdgeInsets.all(2),
                       
child: Text(
                         
'Recently played',
                         
style: context.headlineSmall,
                       
),
                     
),
                     
HomeRecent(playlists: playlists),
                   
],
                 
),
               
),
               
AdaptiveContainer(
                 
columnSpan: 12,
                 
child: Padding(
                   
padding: const EdgeInsets.all(2),
                   
child: Row(
                     
crossAxisAlignment: CrossAxisAlignment.start,
                     
children: [
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'Top Songs Today',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: topSongs,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                       
Flexible(
                         
flex: 10,
                         
child: Column(
                           
mainAxisAlignment: MainAxisAlignment.start,
                           
crossAxisAlignment: CrossAxisAlignment.start,
                           
children: [
                             
Padding(
                               
padding: const EdgeInsets.all(2),
                               
child: Text(
                                 
'New Releases',
                                 
style: context.titleLarge,
                               
),
                             
),
                             
LayoutBuilder(
                               
builder: (context, constraints) =>
                                   
PlaylistSongs(
                                     
playlist: newReleases,
                                     
constraints: constraints,
                                   
),
                             
),
                           
],
                         
),
                       
),
                     
],
                   
),
                 
),
               
),
             
],
           
),
         
),
       
);
     
},
   
);
 
}
}

377cfdda63a9de54.png

هل لديك مشاكل؟

إذا لم يكن تطبيقك يعمل بشكل صحيح، استخدِم الرمز الوارد في الرابط التالي لاستعادة الأداء الطبيعي.

7. استخدام المسافات البيضاء

المساحة البيضاء هي أداة مرئية مهمة لتطبيقك، إذ تُنشئ فواصل تنظيمية بين الأقسام.

من الأفضل أن يكون هناك الكثير من المسافات البيضاء بدلاً من عدم توفّرها. من الأفضل إضافة المزيد من المسافات البيضاء بدلاً من تصغير حجم الخط أو العناصر المرئية لكي تلائم المساحة.

يمكن أن يشكّل عدم توفّر مسافات بيضاء مشكلة للأشخاص الذين يعانون من مشاكل في الرؤية. يمكن أن تؤدي المسافة البيضاء الزائدة إلى عدم الانسجام وجعل واجهة المستخدم تبدو غير منظَّمة. على سبيل المثال، اطّلِع على لقطات الشاشة التالية:

7f5e3514a7ee1750.png

d5144a50f5b4142c.png

بعد ذلك، ستضيف مسافة بيضاء إلى الشاشة الرئيسية لتوفير مساحة أكبر. بعد ذلك، يمكنك تعديل التنسيق لتحسين المسافة بين العناصر.

لإضافة مسافة بيضاء حول تطبيق مصغّر، لفّه بعنصر Padding. ارفع جميع قيم الحشو في lib/src/features/home/view/home_screen.dart إلى 35:

lib/src/features/home/view/home_screen.dart

return Scaffold(
  body: SingleChildScrollView(
    child: AdaptiveColumn(
      children: [
        AdaptiveContainer(
          columnSpan: 12,
          child: Padding(
            padding: const EdgeInsets.all(35),                   // Modify this line
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Expanded(
                  child: Text(
                    'Good morning',
                    style: context.displaySmall,
                  ),
                ),
                const SizedBox(width: 20),
                const BrightnessToggle(),
              ],
            ),
          ),
        ),
        AdaptiveContainer(
          columnSpan: 12,
          child: Column(
            children: [
              const HomeHighlight(),
              LayoutBuilder(
                builder: (context, constraints) => HomeArtists(
                  artists: artists,
                  constraints: constraints,
                ),
              ),
            ],
          ),
        ),
        AdaptiveContainer(
          columnSpan: 12,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.all(35),               // Modify this line
                child: Text(
                  'Recently played',
                  style: context.headlineSmall,
                ),
              ),
              HomeRecent(playlists: playlists),
            ],
          ),
        ),
        AdaptiveContainer(
          columnSpan: 12,
          child: Padding(
            padding: const EdgeInsets.all(35),                   // Modify this line
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Flexible(
                  flex: 10,
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.all(35),       // Modify this line
                        child: Text(
                          'Top Songs Today',
                          style: context.titleLarge,
                        ),
                      ),
                      LayoutBuilder(
                        builder: (context, constraints) =>
                            PlaylistSongs(
                              playlist: topSongs,
                              constraints: constraints,
                            ),
                      ),
                    ],
                  ),
                ),
                Flexible(
                  flex: 10,
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.all(35),       // Modify this line
                        child: Text(
                          'New Releases',
                          style: context.titleLarge,
                        ),
                      ),
                      LayoutBuilder(
                        builder: (context, constraints) =>
                            PlaylistSongs(
                              playlist: newReleases,
                              constraints: constraints,
                            ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  ),
);

أعِد تحميل التطبيق بسرعة. من المفترض أن يبدو كما كان من قبل، ولكن مع مساحة بيضاء أكبر بين التطبيقات المصغّرة. يبدو الشريط الإضافي للتباعد أفضل، ولكنّ بانر "أهم التفاصيل" في أعلى الصفحة لا يزال قريبًا جدًا من الحواف.

في lib/src/features/home/view/home_highlight.dart، غيِّر سمة الحشو في البانر إلى 15:

lib/src/features/home/view/home_highlight.dart

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

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            padding: const EdgeInsets.all(15),                   // Modify this line
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launchUrl(Uri.parse('https://docs.flutter.dev')),
            ),
          ),
        ),
      ],
    );
  }
}

أعِد تحميل التطبيق فورًا. لا تتضمّن قائمتا التشغيل في أسفل الصفحة أي مسافة بينهما، لذا يبدو أنّهما تنتميان إلى الجدول نفسه. لا داعي للقلق، وسنساعدك في حلّ هذه المشكلة في الخطوة التالية.

df1d9af97d039cc8.png

أضِف مسافة بين قوائم التشغيل من خلال إدراج تطبيق مصغّر للحجم في Row الذي يحتوي عليها. في lib/src/features/home/view/home_screen.dart، أضِف SizedBox بعرض 35:

lib/src/features/home/view/home_screen.dart

AdaptiveContainer(
  columnSpan: 12,
  child: Padding(
    padding: const EdgeInsets.all(35),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Flexible(
          flex: 10,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.all(35),
                child: Text(
                  'Top Songs Today',
                  style: context.titleLarge,
                ),
              ),
              LayoutBuilder(
                builder: (context, constraints) =>
                    PlaylistSongs(
                      playlist: topSongs,
                      constraints: constraints,
                    ),
              ),
            ],
          ),
        ),
        const SizedBox(width: 35),                                  // Add this line
        Flexible(
          flex: 10,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.all(35),
                child: Text(
                  'New Releases',
                  style: context.titleLarge,
                ),
              ),
              LayoutBuilder(
                builder: (context, constraints) =>
                    PlaylistSongs(
                      playlist: newReleases,
                      constraints: constraints,
                    ),
              ),
            ],
          ),
        ),
      ],
    ),
  ),
),

أعِد تحميل التطبيق بسرعة. من المفترض أن يظهر التطبيق على النحو التالي:

d8b2a3d47736dbab.png

تتوفّر الآن مساحة كبيرة لعرض محتوى الشاشة الرئيسية، ولكن يبدو أنّ كل عنصر مفصود عن الآخر ولا تتوفّر رابطة بين الأقسام.

حتى الآن، تم ضبط كلّ المسافة البادئة (الأفقية والرأسية) للتطبيقات المصغّرة على الشاشة الرئيسية على 35 باستخدام EdgeInsets.all(35)، ولكن يمكنك ضبط المسافة البادئة لكلّ من الحواف بشكل مستقل أيضًا. يمكنك تخصيص الحشو ليلائم المساحة بشكل أفضل.

  • تُستخدَم القيمة EdgeInsets.LTRB() لضبط لليسار والأعلى واليمين والأسفل بشكلٍ منفصل.
  • تضبط EdgeInsets.symmetric() المساحة الفارغة للعرض العمودي (الأعلى والأسفل) لتكون متكافئة، والعرض الأفقي (اليسار واليمين) ليكون متكافئًا.
  • تضبط EdgeInsets.only() الحواف المحدّدة فقط.

lib/src/features/home/view/home_screen.dart

return Scaffold(
  body: SingleChildScrollView(
    child: AdaptiveColumn(
      children: [
        AdaptiveContainer(
          columnSpan: 12,
          child: Padding(
            padding: const EdgeInsets.fromLTRB(20, 25, 20, 10),  // Modify this line
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Expanded(
                  child: Text(
                    'Good morning',
                    style: context.displaySmall,
                  ),
                ),
                const SizedBox(width: 20),
                const BrightnessToggle(),
              ],
            ),
          ),
        ),
        AdaptiveContainer(
          columnSpan: 12,
          child: Column(
            children: [
              const HomeHighlight(),
              LayoutBuilder(
                builder: (context, constraints) => HomeArtists(
                  artists: artists,
                  constraints: constraints,
                ),
              ),
            ],
          ),
        ),
        AdaptiveContainer(
          columnSpan: 12,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.symmetric(             // Modify from here...
                  horizontal: 15,
                  vertical: 10,
                ),                                               // To here.
                child: Text(
                  'Recently played',
                  style: context.headlineSmall,
                ),
              ),
              HomeRecent(playlists: playlists),
            ],
          ),
        ),
        AdaptiveContainer(
          columnSpan: 12,
          child: Padding(
            padding: const EdgeInsets.all(15),                   // Modify this line
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Flexible(
                  flex: 10,
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(          // Modify from here...
                          left: 8,
                          bottom: 8,
                        ),                                       // To here.
                        child: Text(
                          'Top Songs Today',
                          style: context.titleLarge,
                        ),
                      ),
                      LayoutBuilder(
                        builder: (context, constraints) =>
                            PlaylistSongs(
                              playlist: topSongs,
                              constraints: constraints,
                            ),
                      ),
                    ],
                  ),
                ),
                const SizedBox(width: 25),                       // Modify this line
                Flexible(
                  flex: 10,
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(          // Modify from here...
                          left: 8,
                          bottom: 8,
                        ),                                       // To here.
                        child: Text(
                          'New Releases',
                          style: context.titleLarge,
                        ),
                      ),
                      LayoutBuilder(
                        builder: (context, constraints) =>
                            PlaylistSongs(
                              playlist: newReleases,
                              constraints: constraints,
                            ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  ),
);

في lib/src/features/home/view/home_highlight.dart، اضبط المساحة الفارغة على يمين البانر ويساره على 35، والمساحة الفارغة في الأعلى والأسفل على 5:

lib/src/features/home/view/home_highlight.dart

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

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            // Modify the following line
            padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 5),
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launchUrl(Uri.parse('https://docs.flutter.dev')),
            ),
          ),
        ),
      ],
    );
  }
}

إعادة تحميل التطبيق بسرعة. يبدو التنسيق والتباعد أفضل بكثير. أضِف بعض الصور المتحركة والتأثيرات لإضافة لمسة نهائية.

7f5e3514a7ee1750.png

هل لديك مشاكل؟

إذا لم يكن تطبيقك يعمل بشكل صحيح، استخدِم الرمز الوارد في الرابط التالي لاستعادة الأداء الطبيعي.

8. إضافة الحركة والصور المتحركة

إنّ الصور المتحركة والحركة هما طريقتان رائعتان لإضفاء الحيوية والنشاط، ولتقديم ملاحظات عندما يتفاعل المستخدم مع التطبيق.

إضافة تأثيرات متحركة بين الشاشات

يحدِّد ThemeProvider PageTransitionsTheme مع الرسوم المتحرّكة لنقل الشاشة للأنظمة الأساسية للأجهزة الجوّالة (iOS وAndroid). يتلقّى مستخدمو أجهزة الكمبيوتر المكتبي حاليًا ملاحظات من خلال النقر على الماوس أو لوحة اللمس، لذا ليس هناك حاجة إلى استخدام صورة متحركة لنقل الصفحة.

يوفّر Flutter صورًا متحركة لنقل الشاشة يمكنك ضبطها لتطبيقك استنادًا إلى النظام الأساسي المستهدَف كما هو موضّح في lib/src/shared/providers/theme.dart:

lib/src/shared/providers/theme.dart

final pageTransitionsTheme = const PageTransitionsTheme(
  builders: <TargetPlatform, PageTransitionsBuilder>{
    TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
    TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
  },
);

تمرير PageTransitionsTheme إلى كل من المظهرَين الفاتح والداكن في lib/src/shared/providers/theme.dart

lib/src/shared/providers/theme.dart

ThemeData light([Color? targetColor]) {
  final colorScheme = colors(Brightness.light, targetColor);
  return ThemeData.light().copyWith(
    pageTransitionsTheme: pageTransitionsTheme,                     // Add this line
    colorScheme: colorScheme,
    appBarTheme: appBarTheme(colorScheme),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(colorScheme),
    bottomAppBarTheme: bottomAppBarTheme(colorScheme),
    bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
    navigationRailTheme: navigationRailTheme(colorScheme),
    tabBarTheme: tabBarTheme(colorScheme),
    drawerTheme: drawerTheme(colorScheme),
    scaffoldBackgroundColor: colorScheme.surface,
  );
}

ThemeData dark([Color? targetColor]) {
  final colorScheme = colors(Brightness.dark, targetColor);
  return ThemeData.dark().copyWith(
    pageTransitionsTheme: pageTransitionsTheme,                     // Add this line
    colorScheme: colorScheme,
    appBarTheme: appBarTheme(colorScheme),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(colorScheme),
    bottomAppBarTheme: bottomAppBarTheme(colorScheme),
    bottomNavigationBarTheme: bottomNavigationBarTheme(colorScheme),
    navigationRailTheme: navigationRailTheme(colorScheme),
    tabBarTheme: tabBarTheme(colorScheme),
    drawerTheme: drawerTheme(colorScheme),
    scaffoldBackgroundColor: colorScheme.surface,
  );
}

بدون مؤثرات حركية على أجهزة iOS

مع رسوم متحركة على أجهزة iOS

هل لديك مشاكل؟

إذا لم يكن تطبيقك يعمل بشكل صحيح، استخدِم الرمز الوارد في الرابط التالي لاستعادة الأداء الطبيعي.

9. إضافة حالات التمرير بمؤشر الماوس

من الطرق التي يمكنك من خلالها إضافة حركة إلى تطبيق سطح المكتب هي حالات التمرير، حيث يغيّر التطبيق المصغّر حالته (مثل اللون أو الشكل أو المحتوى) عندما يمرّر المستخدم مؤشر الماوس فوقه.

تعرض فئة _OutlinedCardState تلقائيًا MouseRegion، وهي علامة تحوّل سهم المؤشر إلى مؤشر عند التمرير فوقه، ولكن يمكنك إضافة المزيد من الملاحظات المرئية.

افتح lib/src/shared/views/outlined_card.dart واستبدِل محتوياته بالتنفيذ التالي لإضافة حالة _hovered.

lib/src/shared/views/outlined_card.dart

import 'package:flutter/material.dart';

class OutlinedCard extends StatefulWidget {
 
const OutlinedCard({super.key, required this.child, this.clickable = true});

 
final Widget child;
 
final bool clickable;

 
@override
 
State<OutlinedCard> createState() => _OutlinedCardState();
}

class _OutlinedCardState extends State<OutlinedCard> {
 
bool _hovered = false;

 
@override
 
Widget build(BuildContext context) {
   
final borderRadius = BorderRadius.circular(_hovered ? 20 : 8);
   
const animationCurve = Curves.easeInOut;
   
return MouseRegion(
     
onEnter: (_) {
       
if (!widget.clickable) return;
       
setState(() {
         
_hovered = true;
       
});
     
},
     
onExit: (_) {
       
if (!widget.clickable) return;
       
setState(() {
         
_hovered = false;
       
});
     
},
     
cursor: widget.clickable
         
? SystemMouseCursors.click
         
: SystemMouseCursors.basic,
     
child: AnimatedContainer(
       
duration: kThemeAnimationDuration,
       
curve: animationCurve,
       
decoration: BoxDecoration(
         
border: Border.all(
           
color: Theme.of(context).colorScheme.outline,
           
width: 1,
         
),
         
borderRadius: borderRadius,
       
),
       
foregroundDecoration: BoxDecoration(
         
color: Theme.of(
           
context,
         
).colorScheme.onSurface.withAlpha(_hovered ? 30 : 0),
         
borderRadius: borderRadius,
       
),
       
child: TweenAnimationBuilder<BorderRadius>(
         
duration: kThemeAnimationDuration,
         
curve: animationCurve,
         
tween: Tween(begin: BorderRadius.zero, end: borderRadius),
         
builder: (context, borderRadius, child) => ClipRRect(
           
clipBehavior: Clip.antiAlias,
           
borderRadius: borderRadius,
           
child: child,
         
),
         
child: widget.child,
       
),
     
),
   
);
 
}
}

أعِد تحميل التطبيق بسرعة، ثم مرِّر مؤشر الماوس فوق أحد مربّعات قوائم التشغيل التي تم تشغيلها مؤخرًا.

يغيّر OutlinedCard درجة التعتيم ويُدير الزوايا.

أخيرًا، يمكنك إضافة مؤثرات متحركة إلى رقم الأغنية في قائمة تشغيل لتحويله إلى زر تشغيل باستخدام التطبيق المصغّر HoverableSongPlayButton الذي تم تحديده في lib/src/shared/views/hoverable_song_play_button.dart. في lib/src/features/playlists/view/playlist_songs.dart، احط التطبيق المصغّر Center (الذي يحتوي على رقم الأغنية) بعنصر HoverableSongPlayButton:

lib/src/features/playlists/view/playlist_songs.dart

rowBuilder: (context, index) => DataRow.byIndex(
  index: index,
  cells: [
    DataCell(
      HoverableSongPlayButton(                                      // Modify from here...
        hoverMode: HoverMode.overlay,
        song: playlist.songs[index],
        child: Center(
          child: Text(
            (index + 1).toString(),
            textAlign: TextAlign.center,
          ),
        ),
      ),                                                            // To here.
    ),
    DataCell(
      Row(
        children: [
          Padding(
            padding: const EdgeInsets.all(2),
            child: ClippedImage(playlist.songs[index].image.image),
          ),
          const SizedBox(width: 10),
          Expanded(child: Text(playlist.songs[index].title)),
        ],
      ),
    ),
    DataCell(Text(playlist.songs[index].length.toHumanizedString())),
  ],
),

أعِد تحميل التطبيق بسرعة، ثم مرِّر مؤشر الماوس فوق رقم الأغنية في قائمة أبرز الأغاني اليوم أو قائمة الإصدارات الجديدة.

يتحول الرقم إلى صورة متحركة لزر تشغيل يشغّل الأغنية عند النقر عليه.

اطّلِع على رمز المشروع النهائي على GitHub.

10. تهانينا!

لقد أكملت هذا الدليل التعليمي حول البرمجة. لقد تعرّفت على العديد من التغييرات الصغيرة التي يمكنك دمجها في التطبيق لجعله أكثر جمالًا، وأكثر سهولة في الاستخدام وقابلية للترجمة والأقلمة، وأكثر ملاءمةً لمنصّات متعددة. وتشمل هذه الأساليب، على سبيل المثال لا الحصر:

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

من خلال إجراء بعض التعديلات الصغيرة، يمكنك تحويل تطبيقك من تطبيق ممل إلى تطبيق جميل:

قبل

1e67c60667821082.png

بعد

الخطوات التالية

نأمل أن تكون قد تعرّفت على مزيد من المعلومات حول إنشاء تطبيقات رائعة في Flutter.

إذا طبّقت أيًا من النصائح أو الحيل المذكورة هنا (أو إذا كانت لديك نصيحة تريد مشاركتها)، يسعدنا معرفة رأيك. يمكنك التواصل معنا على Twitter على ‎@rodydavis و‎@khanhnwin.

يمكنك أيضًا الاطّلاع على المراجع التالية.

المظهر

المراجع التكيُّفية وتلك السريعة الاستجابة:

مراجع التصميم العامة:

يمكنك أيضًا التواصل مع منتدى Flutter.

نتمنّى لك التوفيق في إنشاء تطبيقات جميلة.