diff --git a/assets/images/login_cover.jpg b/assets/images/login_cover.jpg new file mode 100644 index 0000000..091112d Binary files /dev/null and b/assets/images/login_cover.jpg differ diff --git a/assets/images/logo_coin.png b/assets/images/logo_coin.png new file mode 100644 index 0000000..ca60974 Binary files /dev/null and b/assets/images/logo_coin.png differ diff --git a/lib/features/auth/login_screen.dart b/lib/features/auth/login_screen.dart index f68dcd0..c60887c 100644 --- a/lib/features/auth/login_screen.dart +++ b/lib/features/auth/login_screen.dart @@ -205,41 +205,22 @@ class _BrandPanel extends StatelessWidget { ), child: Stack( children: [ - Positioned( - right: large ? -120 : -50, - top: large ? -80 : -30, - child: _ring(large ? 420 : 190, C.onNavy8), - ), - Positioned( - left: large ? -90 : -40, - bottom: large ? -60 : 10, - child: _ring(large ? 300 : 120, C.onNavy5), + // A wall of ticket endings with a few gold ones, calmed in the + // middle so the coin and name stay clear. Decorative only. + Positioned.fill( + child: Image.asset( + 'assets/images/login_cover.jpg', + fit: BoxFit.cover, + excludeFromSemantics: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), ), SafeArea( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: coin, - height: coin, - decoration: const BoxDecoration( - color: C.gold, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: C.goldGlow, - blurRadius: 30, - offset: Offset(0, 10), - ), - ], - ), - child: Icon( - Icons.confirmation_number_rounded, - color: C.navy, - size: coin * 0.48, - ), - ), + BrandCoin(size: coin), SizedBox(height: large ? S.s24 : S.s18), Text( 'SRS', @@ -247,7 +228,7 @@ class _BrandPanel extends StatelessWidget { ), const SizedBox(height: S.s6), Text( - 'Your sequences, at a glance', + 'Your ticket sequences, at a glance', style: ts(large ? 16 : 14, c: C.softOnNavy), ), ], @@ -258,10 +239,4 @@ class _BrandPanel extends StatelessWidget { ), ); } - - Widget _ring(double size, Color color) => Container( - width: size, - height: size, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ); } diff --git a/lib/main.dart b/lib/main.dart index 361ec98..4c75c3e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,7 +7,10 @@ import 'core/api.dart'; import 'core/config.dart'; import 'core/format.dart'; import 'core/http_api.dart'; +import 'core/models.dart'; import 'core/providers.dart'; +import 'core/theme.dart'; +import 'widgets/ui.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -15,18 +18,56 @@ Future main() async { baseUrl: AppConfig.apiBaseUrl, prefs: await SharedPreferences.getInstance(), ); + runApp(_Boot(api)); +} - final user = await api.restore(); - final start = dateOnly(api.now); +class _Boot extends StatefulWidget { + const _Boot(this.api); + final Api api; - runApp( - ProviderScope( - overrides: [ - apiProvider.overrideWithValue(api), - startDateProvider.overrideWithValue(start), - sessionProvider.overrideWith(() => SessionNotifier(user)), - ], - child: const SrsApp(), + @override + State<_Boot> createState() => _BootState(); +} + +class _BootState extends State<_Boot> { + late final Future _user = widget.api.restore().catchError( + (Object _) => null, + ); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _user, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const _Splash(); + } + return ProviderScope( + overrides: [ + apiProvider.overrideWithValue(widget.api), + startDateProvider.overrideWithValue(dateOnly(widget.api.now)), + sessionProvider.overrideWith(() => SessionNotifier(snap.data)), + ], + child: const SrsApp(), + ); + }, + ); +} + +class _Splash extends StatelessWidget { + const _Splash(); + + @override + Widget build(BuildContext context) => Directionality( + textDirection: TextDirection.ltr, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [C.navy, C.navyDeep], + ), + ), + child: const Center(child: BrandCoin(size: 96)), ), ); } diff --git a/lib/widgets/main_shell.dart b/lib/widgets/main_shell.dart index 7352d32..9c0ba8d 100644 --- a/lib/widgets/main_shell.dart +++ b/lib/widgets/main_shell.dart @@ -237,16 +237,7 @@ class _Brand extends StatelessWidget { @override Widget build(BuildContext context) { - final coin = Container( - width: 40, - height: 40, - decoration: const BoxDecoration(color: C.gold, shape: BoxShape.circle), - child: const Icon( - Icons.confirmation_number_rounded, - color: C.navy, - size: 20, - ), - ); + const coin = BrandCoin(size: 40, glow: false); if (!extended) return coin; return Row( children: [ diff --git a/lib/widgets/ui.dart b/lib/widgets/ui.dart index 8d83ebd..07c786b 100644 --- a/lib/widgets/ui.dart +++ b/lib/widgets/ui.dart @@ -1400,3 +1400,32 @@ class DateStrip extends ConsumerWidget { ); } } + +class BrandCoin extends StatelessWidget { + const BrandCoin({super.key, required this.size, this.glow = true}); + final double size; + final bool glow; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: glow + ? [ + BoxShadow( + color: C.goldGlow, + blurRadius: size * 0.36, + offset: Offset(0, size * 0.12), + ), + ] + : null, + ), + child: Image.asset( + 'assets/images/logo_coin.png', + width: size, + height: size, + excludeFromSemantics: true, + filterQuality: FilterQuality.medium, + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 98a7cd7..8221810 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,8 @@ flutter: uses-material-design: true assets: - assets/icons/whatsapp.svg + - assets/images/login_cover.jpg + - assets/images/logo_coin.png fonts: - family: Inter fonts: diff --git a/tool/brand_art/logo_coin_master.png b/tool/brand_art/logo_coin_master.png new file mode 100644 index 0000000..fb92fca Binary files /dev/null and b/tool/brand_art/logo_coin_master.png differ diff --git a/tool/brand_art/make_cover.py b/tool/brand_art/make_cover.py new file mode 100644 index 0000000..4c9cd5e --- /dev/null +++ b/tool/brand_art/make_cover.py @@ -0,0 +1,46 @@ +import random, math +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops +import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from make_icons import gradient, NAVY, DEEP, GOLD, FONT + +W,H=1600,1600 # square: crops cleanly into the tall left panel and the phone banner +random.seed(74) +base=gradient((W,H),NAVY,DEEP).convert("RGBA") + +# Wall of ticket endings, tilted, in two quiet tones with a few gold ones. +WW=2800 +wall=Image.new("RGBA",(WW,WW),(0,0,0,0)); d=ImageDraw.Draw(wall) +big=ImageFont.truetype(FONT,138) +cw,ch=440,230 +cols,rows=WW//cw+1,WW//ch+1 +gold_cells={(2,3),(5,5),(3,8),(6,2),(1,10),(4,6)} +for r in range(rows): + for c in range(cols): + txt="%04d"%random.randint(0,9999) + x=c*cw+(cw//2 if r%2 else 0)-80; y=r*ch-40 + if (c,r) in gold_cells: + fill=GOLD+(215,) + else: + fill=(255,255,255,20 if (c+r)%3 else 32) + d.text((x,y),txt,font=big,fill=fill) +wall=wall.rotate(-12,resample=Image.BICUBIC) +left=(WW-W)//2; top=(WW-H)//2 +wall=wall.crop((left,top,left+W,top+H)) + +# Calm the middle, where the coin and wordmark sit, and darken the edges. +mask=Image.new("L",(W,H),255); md=ImageDraw.Draw(mask) +md.ellipse([W*0.08,H*0.18,W*0.92,H*0.82],fill=14) +mask=mask.filter(ImageFilter.GaussianBlur(200)) +wall.putalpha(ImageChops.multiply(wall.getchannel("A"),mask)) +img=Image.alpha_composite(base,wall) + +vig=Image.new("L",(W,H),0); vd=ImageDraw.Draw(vig) +vd.ellipse([-W*0.15,-H*0.15,W*1.15,H*1.15],fill=255) +vig=vig.filter(ImageFilter.GaussianBlur(220)) +dark=Image.new("RGBA",(W,H),DEEP+(255,)) +dark.putalpha(Image.eval(vig,lambda v:int((255-v)*0.75))) +img=Image.alpha_composite(img,dark).convert("RGB") +out="C:/Project-1/srs/assets/images/login_cover.jpg" +import os; os.makedirs(os.path.dirname(out),exist_ok=True) +img.save(out,quality=84,optimize=True,progressive=True) +print(out,img.size,os.path.getsize(out)//1024,"KB") diff --git a/tool/brand_art/make_icons.py b/tool/brand_art/make_icons.py new file mode 100644 index 0000000..fe11593 --- /dev/null +++ b/tool/brand_art/make_icons.py @@ -0,0 +1,116 @@ +import os, random, sys +from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +NAVY = (0x1B, 0x2A, 0x49); DEEP = (0x10, 0x1B, 0x33); GOLD = (0xD4, 0xA6, 0x4A) +FONT = os.path.join(ROOT, "assets", "fonts", "Inter-Bold.otf") +COIN = Image.open(os.path.join(HERE, "logo_coin_master.png")).convert("RGBA") + + +def gradient(size, c1, c2): + w, h = size + g = Image.new("RGB", (w, h)) + px = g.load() + for y in range(h): + for x in range(w): + t = (x / w + y / h) / 2 + px[x, y] = tuple(round(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)) + return g + + +def background(S, style): + bg = gradient((S, S), NAVY, DEEP).convert("RGBA") + ov = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + d = ImageDraw.Draw(ov) + if style == "circles": + # Large soft discs and one thin gold ring, partly off the tile. + d.ellipse([S * 0.46, -S * 0.42, S * 1.34, S * 0.46], fill=(255, 255, 255, 20)) + d.ellipse([-S * 0.42, S * 0.52, S * 0.42, S * 1.36], fill=(255, 255, 255, 14)) + d.ellipse([S * 0.58, S * 0.60, S * 1.18, S * 1.20], fill=(212, 166, 74, 22)) + d.ellipse([S * 0.04, S * 0.04, S * 0.96, S * 0.96], + outline=(212, 166, 74, 46), width=max(1, round(S * 0.006))) + elif style == "endings": + rnd = random.Random(74) + f = ImageFont.truetype(FONT, round(S * 0.115)) + cw, ch = S * 0.36, S * 0.19 + gold = {(1, 1), (3, 3), (0, 4), (2, 5)} + layer = Image.new("RGBA", (S * 2, S * 2), (0, 0, 0, 0)) + ld = ImageDraw.Draw(layer) + for r in range(int(S * 2 / ch) + 1): + for c in range(int(S * 2 / cw) + 1): + t = "%04d" % rnd.randint(0, 9999) + x = c * cw + (cw / 2 if r % 2 else 0) - S * 0.2 + y = r * ch - S * 0.1 + fill = GOLD + (150,) if (c, r) in gold else (255, 255, 255, 26) + ld.text((x, y), t, font=f, fill=fill) + layer = layer.rotate(-12, resample=Image.BICUBIC) + o = S // 2 + layer = layer.crop((o, o, o + S, o + S)) + ov = layer + return Image.alpha_composite(bg, ov) + + +def icon(px, style, ratio, shape, ss=4): + S = px * ss + bg = background(S, style) + cx = cy = S / 2 + R = S * ratio / 2 + if style == "endings": + # Calm the middle so the coin reads cleanly. + m = Image.new("L", (S, S), 255) + ImageDraw.Draw(m).ellipse([cx - R * 1.55, cy - R * 1.55, cx + R * 1.55, cy + R * 1.55], fill=70) + m = m.filter(ImageFilter.GaussianBlur(S * 0.07)) + veil = Image.new("RGBA", (S, S), NAVY + (255,)) + veil.putalpha(Image.eval(m, lambda v: 255 - v)) + bg = Image.alpha_composite(bg, veil) + glow = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + ImageDraw.Draw(glow).ellipse([cx - R, cy - R + S * 0.02, cx + R, cy + R + S * 0.02], fill=GOLD + (130,)) + glow = glow.filter(ImageFilter.GaussianBlur(S * 0.035)) + bg = Image.alpha_composite(bg, glow) + coin = COIN.resize((round(R * 2), round(R * 2)), Image.LANCZOS) + bg.alpha_composite(coin, (round(cx - R), round(cy - R))) + if shape == "rounded": + mask = Image.new("L", (S, S), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, S - 1, S - 1], radius=S * 0.22, fill=255) + bg.putalpha(mask) + return bg.resize((px, px), Image.LANCZOS) + + +def write_icons(style): + web = os.path.join(ROOT, "web") + jobs = [ + ("icons/Icon-512.png", 512, 0.62, "rounded"), + ("icons/Icon-192.png", 192, 0.62, "rounded"), + ("icons/Icon-maskable-512.png", 512, 0.52, "full"), + ("icons/Icon-maskable-192.png", 192, 0.52, "full"), + ("icons/apple-touch-icon.png", 180, 0.62, "full"), + ("favicon.png", 64, 0.78, "rounded"), + ] + for name, px, ratio, shape in jobs: + icon(px, style, ratio, shape).save(os.path.join(web, name), optimize=True) + print(name, px) + + +def write_preview(): + out = os.path.join(HERE, "preview") + os.makedirs(out, exist_ok=True) + for style in ("endings", "circles"): + sheet = Image.new("RGB", (512 + 40 + 192 + 40 + 64 + 40 + 32, 512 + 40), (244, 242, 236)) + big = icon(512, style, 0.62, "rounded") + sheet.paste(big, (20, 20), big) + mid = icon(192, style, 0.62, "rounded") + sheet.paste(mid, (512 + 40, 20), mid) + small = icon(64, style, 0.78, "rounded") + sheet.paste(small, (512 + 40 + 192 + 20, 20), small) + tiny = icon(32, style, 0.78, "rounded") + sheet.paste(tiny, (512 + 40 + 192 + 20 + 64 + 20, 20), tiny) + sheet.save(os.path.join(out, f"icon_{style}.png")) + print("preview", style) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "preview": + write_preview() + else: + write_icons(sys.argv[1] if len(sys.argv) > 1 else "circles") diff --git a/tool/brand_art/preview/icon_circles.png b/tool/brand_art/preview/icon_circles.png new file mode 100644 index 0000000..7f557dd Binary files /dev/null and b/tool/brand_art/preview/icon_circles.png differ diff --git a/tool/brand_art/preview/icon_endings.png b/tool/brand_art/preview/icon_endings.png new file mode 100644 index 0000000..a1070ea Binary files /dev/null and b/tool/brand_art/preview/icon_endings.png differ diff --git a/web/favicon.png b/web/favicon.png index 2027ed4..90da938 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 9e1a09f..429a7ce 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 1a15ce4..02f448b 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index d01f4a2..34e9c1d 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index 501ef34..2c54f8a 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/icons/apple-touch-icon.png b/web/icons/apple-touch-icon.png new file mode 100644 index 0000000..7b8fa68 Binary files /dev/null and b/web/icons/apple-touch-icon.png differ diff --git a/web/icons/coin.png b/web/icons/coin.png new file mode 100644 index 0000000..08b6297 Binary files /dev/null and b/web/icons/coin.png differ diff --git a/web/index.html b/web/index.html index 1ca5eb4..3b5533f 100644 --- a/web/index.html +++ b/web/index.html @@ -1,30 +1,17 @@ - - + - + @@ -32,16 +19,36 @@ SRS + - + + diff --git a/web/manifest.json b/web/manifest.json index aa8b297..e89570c 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -3,9 +3,9 @@ "short_name": "SRS", "start_url": ".", "display": "standalone", - "background_color": "#F4F2EC", + "background_color": "#1B2A49", "theme_color": "#1B2A49", - "description": "Your sequences, at a glance", + "description": "Your ticket sequences, at a glance", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [